Create a Fullstack Blog App with Next.js, Prisma 2 and Docker- Part III Build the UI

Learn how to build a fullstack blog application from the ground up from the comfort of your docker environment.

In the final post of this series, we will build out the UI for this Blog site built with Next.js, Prisma 2 and Docker. Be sure to check out the first two posts to make sure you are caught up first.

Where's the code?

We have the final code posted over on Github.

#Table of Contents:

Part I- Set up the Repo and Configure the Backend

  1. Create the base repo

  2. Create the Next.js frontend

  3. Create the boilerplate Prisma 2 backend

  4. Dockerize our web app

    a. Create a docker-compose file

    b. Add Dockerfiles for each container

  5. Configure the backend

    a. Switch database from SQLite to MySQL

    b. Remove unused backend boilerplate code

    c. Update backend queries in Prisma Nexus

  6. Verify our Docker-Compose cluster works

Part II- Configure the Frontend

  1. Add GraphQL fragments, queries and resolvers
  2. Add GraphQL-Codegen for generating React Components
  3. Add Apollo and create HOC for Next.js
  4. Add React-Apollo to project root
  5. Install Antd for a beautiful site layout

Part III- Build the UI (this post)

  1. Create the Main Layout
  2. Create a Users Layout Component
  3. Create a Signup User Component
  4. Create a Feed Layout Component
  5. Create a New Draft Component
  6. Create a Publish Draft Button Component
  7. Create a Delete Post Button Component

As always, make sure to check out the end for video walkthroughs.

Part III- Build the UI

1. Create the main layout

Our first step of our UI journey is to create a layout component that we will load on every page. This will contain the header, footer and a passthrough for the rest of the page. Create a main layout file and add the following code:

frontend/components/main-layout.tsx

import React, { ReactNode, Component } from 'react'
import { Layout } from 'antd'
import Link from 'next/link'
import Head from 'next/head'

const { Footer, Header, Content } = Layout

type Props = {
  title?: string
  children: ReactNode
}

class MainLayout extends Component<Props> {
  render() {
    const { children, title } = this.props
    return (
      <Layout>
        <Head>
          <title>{title}</title>
          <meta charSet="utf-8" />
          <meta
            name="viewport"
            content="initial-scale=1.0, width=device-width"
          />
        </Head>
        <Header>
          <nav>
            <Link href="/">
              <a>Home</a>
            </Link>
          </nav>
        </Header>
        <Content>{children}</Content>
        <Footer>
          <hr />
          <span>I'm here to stay (Footer)</span>
        </Footer>
      </Layout>
    )
  }
}

export default MainLayout

Update the index page to add the Layout component. You'll need to add this Layout on every page you make in the future, but in our case we only have the one:

frontend/pages/index.tsx

import * as React from 'react'
import { NextPage } from 'next'
import Layout from '../components/main-layout'

const IndexPage: NextPage = () => {
  return (
    <Layout title="Blog Layout">
      <h1>Simple Prisma 2 Blog Example</h1>
    </Layout>
  )
}

export default IndexPage

When you reload your page, it should have a header, footer and the body should be a gray color:

Ant Design Layout Theme added.
Ant Design Layout Theme added.

2. Create a Users Layout Component

Now that we have Ant Design installed, Apollo configured and our Apollo components autogenerated, it is time to get started making our components. Let's start with a users layout.

frontend/components/users.tsx

import React from 'react'
import { Table } from 'antd'
import { UsersQueryComponent } from '../generated/apollo-components'

type Props = {}

class UsersList extends React.PureComponent<Props> {
  render() {
    return (
      <UsersQueryComponent>
        {({ loading, error, data }) => {
          if (loading) return <p>Loading...</p>
          if (error) return <p>Error</p>

          if (data && 'users' in data && data.users.length > 0) {
            const feedData = data.users.map(({ name, email }, i) => ({
              key: i,
              name,
              email,
            }))
            const columns = [
              {
                title: 'Name',
                dataIndex: 'name',
                key: 'name',
              },
              {
                title: 'Email',
                dataIndex: 'email',
                key: 'email',
              },
            ]
            return <Table columns={columns} dataSource={feedData} />
          }

          return <p>No users yet.</p>
        }}
      </UsersQueryComponent>
    )
  }
}

export default UsersList

We are using an autogenerated component that is called UsersQueryComponent that was made by the GraphQL Codegen plugin. It is doing all the hard work of fetching our data. We first check that it isn't loading and there isn't an error and then we pull off a list of users from the data object.

Antd has a table component that we can feed it an array of objects and a list of column names and it will create a beautiful table for us. If there aren't any users we just report that back instead.

Now we can import that UsersList component into our index.tsx file:

frontend/pages/index.tsx

import * as React from 'react'
import { NextPage } from 'next'
import Layout from '../components/main-layout'
import UsersList from '../components/users'

const IndexPage: NextPage = () => {
  return (
    <Layout title="Blog Layout">
      <h1>Simple Prisma 2 Blog Example</h1>
      <h3>Users List</h3>
      <UsersList />
    </Layout>
  )
}

export default IndexPage

When we look at our webpage now, we should see that it is saying that we don't have any users yet. Let's change that now by creating a signup user component.

Blog page with user's list
Blog page with user's list

3. Create a Signup User Component

Let's create a new file called signup-user.tsx and add the following code:

frontend/components/signup-user.tsx

import React from 'react'
import { Row, Col, Button, Form, Input } from 'antd'
import {
  SignupUserMutationComponent,
  UsersQueryDocument,
} from '../generated/apollo-components'

type Props = {}
const initialState = { name: '', email: '' }
type State = typeof initialState

class SignupUser extends React.Component<Props> {
  state: State = initialState

  handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target
    this.setState({ [name]: value })
  }

  render() {
    return (
      <SignupUserMutationComponent>
        {createUser => (
          <Form
            onSubmit={e => {
              e.preventDefault()
              createUser({
                variables: { ...this.state },
                refetchQueries: [{ query: UsersQueryDocument }],
              }).then(() => {
                this.setState({ name: '', email: '' })
              })
            }}
          >
            <Row>
              <Col span={6}>
                <Form.Item>
                  <Input
                    placeholder="name"
                    name="name"
                    value={this.state.name}
                    onChange={this.handleChange}
                    type="text"
                  />
                </Form.Item>
              </Col>
              <Col span={6}>
                <Form.Item>
                  <Input
                    placeholder="email"
                    name="email"
                    value={this.state.email}
                    onChange={this.handleChange}
                    type="text"
                  />
                </Form.Item>
              </Col>
              <Col span={6}>
                <Form.Item>
                  <Button htmlType="submit">Signup User</Button>
                </Form.Item>
              </Col>
            </Row>
          </Form>
        )}
      </SignupUserMutationComponent>
    )
  }
}

export default SignupUser

We are using a different autogenerated component called SignupUserMutationComponent to trigger our signupUser mutation. The SignupUserMutationComponent yields a method that we call createUser here which allows us to trigger a mutation wherever we'd like. We can pass variables into this method when we call it and they will get added to our request for the backend.

We create an HTML form with several inputs- name and email. We use the SignupUser state to save the input as the user types into the two input fields. Since the input fields display the current state of the react component, the user isn't directly typing into the field but instead is triggering an onClick trigger which is updating the state which we then see reflected in the input field.

When the user presses the submit button, we use an onSubmit trigger to fire off the createUser method which we populate with variables from the state.

After the mutation fires off we have a refetchQuery which will re-run the users query to make sure that our UsersList will have the new user's entry that was just added. Finally, we clear the state variables name and email which will clear out the input fields. This approach is called controlled components in React and is a pretty standard way of doing things, so if it is unfamiliar to you, check out the official documentation for more details (or feel free to reach out directly to me for this or other issues for possible future blog posts!).

Now you can add the SignupUser component to the index page:

frontend/pages/index.tsx

import * as React from 'react'
import { NextPage } from 'next'
import Layout from '../components/main-layout'
import UsersList from '../components/users'
import SignupUser from '../components/signup-user'

const IndexPage: NextPage = () => {
  return (
    <Layout title="Blog Layout">
      <h1>Simple Prisma 2 Blog Example</h1>
      <h3>Signup User</h3>
      <SignupUser />
      <h3>Users List</h3>
      <UsersList />
    </Layout>
  )
}

export default IndexPage

Now go back to the Next.js website and try adding a new user and email. You should see that it gets added to the users list just like this:

Blog page with user signup added.
Blog page with user signup added.

4. Create a Feed Layout Component

Our blog page will use the Feed Layout twice:

  • all the published blog posts
  • all the hidden blog posts

We want to create a FeedList component so that we can display either one based on whether the published boolean that we pass in is true or false.

Create a feed.tsx file and add the following code:

frontend/components/feed.tsx

import React from 'react'
import { Table } from 'antd'
import { FeedQueryComponent } from '../generated/apollo-components'

type Props = {
  published: boolean
}

class FeedList extends React.PureComponent<Props> {
  render() {
    const { published } = this.props
    return (
      <FeedQueryComponent variables={{ published }}>
        {({ loading, error, data }) => {
          if (loading) return <p>Loading...</p>
          if (error) return <p>Error</p>

          if (data && 'feed' in data && data.feed.length > 0) {
            const feedData = data.feed.map(({ id, title, content }, i) => ({
              key: i,
              title,
              content,
              id,
            }))
            const columns = [
              {
                title: 'Title',
                dataIndex: 'title',
                key: 'title',
              },
              {
                title: 'Content',
                dataIndex: 'content',
                key: 'content',
              },
              {
                title: 'Action',
                key: 'action',
                render: () => {
                  return <p>Button Group will go here</p>
                },
              },
            ]
            return <Table columns={columns} dataSource={feedData} />
          }

          return <p>No results yet.</p>
        }}
      </FeedQueryComponent>
    )
  }
}

export default FeedList

The FeedList component looks very similar to the Users component that we already created. We are utilizing the FeedQueryComponent that is autogenerated just like before and now we are going to create a table with 3 columns- title, content and action. The action group will have our buttons for publishing and deleting the posts but we have just stubbed those out for now.

Now modify the index.tsx file in the pages folder to have two instances of the FeedList component- once with the published prop set to true and the second time set to false.

frontend/pages/index.tsx

import * as React from 'react'
import { NextPage } from 'next'
import Layout from '../components/main-layout'
import FeedList from '../components/feed'
import UsersList from '../components/users'
import SignupUser from '../components/signup-user'

const IndexPage: NextPage = () => {
  return (
    <Layout title="Blog Layout">
      <h1>Simple Prisma 2 Blog Example</h1>
      <h3>Create User</h3>
      <SignupUser />
      <h3>Users</h3>
      <UsersList />

      <h3>Feed</h3>
      <FeedList published={true} />
      <h3>Hidden Feed</h3>
      <FeedList published={false} />
    </Layout>
  )
}

export default IndexPage

Now navigate to the Next.js webpage and you should see that it has both Feed components.

Published and Non-published feeds added.
Published and Non-published feeds added.

5. Create a New Draft Component

Now we will create a New Draft Component so we can make new blog posts. This will be very similar to the SignupUser component that we have already made. The goal here is that when the draft is created, we will see it show up on the unpublished list.

Create a new-draft.tsx file and add the following code:

/frontend/components/new-draft.tsx

import React from 'react'
import { Row, Col, Button, Form, Input } from 'antd'
import {
  CreateDraftMutationComponent,
  FeedQueryDocument,
} from '../generated/apollo-components'

type Props = {}
const initialState = { title: '', content: '', authorEmail: '' }
type State = typeof initialState

class NewDraft extends React.Component<Props> {
  state: State = initialState

  handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target
    this.setState({ [name]: value })
  }

  render() {
    return (
      <CreateDraftMutationComponent>
        {createDraft => (
          <Form
            onSubmit={e => {
              e.preventDefault()
              createDraft({
                variables: { ...this.state },
                refetchQueries: [
                  { query: FeedQueryDocument, variables: { published: true } },
                  { query: FeedQueryDocument, variables: { published: false } },
                ],
              }).then(res => {
                console.log(res)
                this.setState({ title: '', content: '', authorEmail: '' })
              })
            }}
          >
            <Row>
              <Col span={6}>
                <Form.Item>
                  <Input
                    placeholder="title"
                    name="title"
                    value={this.state.title}
                    onChange={this.handleChange}
                    type="text"
                  />
                </Form.Item>
              </Col>
              <Col span={6}>
                <Form.Item>
                  <Input
                    placeholder="content"
                    name="content"
                    value={this.state.content}
                    onChange={this.handleChange}
                    type="text"
                  />
                </Form.Item>
              </Col>
              <Col span={6}>
                <Form.Item>
                  <Input
                    placeholder="authorEmail"
                    name="authorEmail"
                    value={this.state.authorEmail}
                    onChange={this.handleChange}
                    type="text"
                  />
                </Form.Item>
              </Col>
              <Col span={6}>
                <Form.Item>
                  <Button htmlType="submit">Create Draft</Button>
                </Form.Item>
              </Col>
            </Row>
          </Form>
        )}
      </CreateDraftMutationComponent>
    )
  }
}

export default NewDraft

The code is very similar to the SignupUser component- we have inputs for title, content, and author email and typing into these fields stores the value in a state which we use as variables in the createDraft mutation when the submit button is pressed.

Note that we are trusting the user to add their email address in the authorEmail field. You would never do this for a real application. You would likely have a user pass a JSON web token (JWT) which would have the user's email encoded inside so that you'd know that you can trust the email you are given. We won't go through how to use JWTs in this example, but this is something that could be added after our application has been completed.

We need to update our index page for a final time to add this NewDraft component.

frontend/pages/index.tsx

import * as React from 'react'
import { NextPage } from 'next'
import Layout from '../components/main-layout'
import FeedList from '../components/feed'
import NewDraft from '../components/new-draft'
import UsersList from '../components/users'
import SignupUser from '../components/signup-user'

const IndexPage: NextPage = () => {
  return (
    <Layout title="Blog Layout">
      <h1>Simple Prisma 2 Blog Example</h1>
      <h3>Create User</h3>
      <SignupUser />
      <h3>Users</h3>
      <UsersList />

      <h3>Create Draft</h3>
      <NewDraft />
      <h3>Feed</h3>
      <FeedList published={true} />
      <h3>Hidden Feed</h3>
      <FeedList published={false} />
    </Layout>
  )
}

export default IndexPage

Now go to the Next.js site and you should see that there is the add draft component. Add a new post, making sure to match the email to the user that you have above and submit it.

New Draft component added to the blog page- before adding new draft.
New Draft component added to the blog page- before adding new draft.

You should see that it gets added to the drafts feed list.

New Draft component added to the blog page- after adding new draft.
New Draft component added to the blog page- after adding new draft.

6. Create a Publish Draft Button Component

Now that we have a draft, let's publish it! We will create a button that will call a publish mutation with a particular post ID as an input parameter. Our backend will call Prisma 2 which will change that post's published field from false to true.

Create a file called publish-draft.tsx and add the following code:

frontend/components/publish-draft.tsx

import React from 'react'
import { Button } from 'antd'
import {
  PublishMutationComponent,
  FeedQueryDocument,
} from '../generated/apollo-components'

type Props = {
  id: string
}

class PublishDraft extends React.Component<Props> {
  render() {
    const { id } = this.props
    return (
      <PublishMutationComponent>
        {publishDraft => (
          <Button
            onClick={() =>
              publishDraft({
                variables: { id },
                refetchQueries: [
                  { query: FeedQueryDocument, variables: { published: true } },
                  { query: FeedQueryDocument, variables: { published: false } },
                ],
              })
            }
          >
            Publish
          </Button>
        )}
      </PublishMutationComponent>
    )
  }
}

export default PublishDraft

We use an auto generated react component just like before and we nest a button inside the PublishMutationComponent component. When the button is clicked, we will call the publish mutation. We have a refetch query here to fetch both published and non published results to ensure that when a post gets published that both lists get updated.

Now we need to update the feed.tsx file to add the PublishDraft to the Action block. Make sure to import the PublishDraft and Button component at the top of the feed.tsx file.

frontend/components/feed.tsx

import { Table, Button } from 'antd'
import PublishDraft from './publish-draft'
// Lines omitted for brevity
//
{
  title: 'Action',
  key: 'action',
  render: ({ id }: { id: string }) => {
    return (
      <Button.Group>
        {published ? null : <PublishDraft id={id} />}
      </Button.Group>
    );
  }
}

Now when you visit your site, you will see that there is a Publish button next to the post that you created.

Publish button added to each unpublished blog.
Publish button added to each unpublished blog.

Push the publish button and you'll see that it moves from the unpublished to published table. Sweet!

After publishing post it shows up in the published feed.
After publishing post it shows up in the published feed.

7. Create a Delete Post Button Component

The final component that we have left is to create a delete button for the posts. This button will be very similar to the PublishDraft component that we created. First, create a file called delete-post.tsx and add the following code:

frontend/components/delete-post.tsx

import React from 'react'
import { Button } from 'antd'
import {
  DeleteOnePostComponent,
  FeedQueryDocument,
} from '../generated/apollo-components'

type Props = {
  id: string
}

class DeletePost extends React.Component<Props> {
  render() {
    const { id } = this.props
    return (
      <DeleteOnePostComponent>
        {deleteOnePost => (
          <Button
            type="danger"
            onClick={() =>
              deleteOnePost({
                variables: { id },
                refetchQueries: [
                  { query: FeedQueryDocument, variables: { published: true } },
                  { query: FeedQueryDocument, variables: { published: false } },
                ],
              })
            }
          >
            Delete
          </Button>
        )}
      </DeleteOnePostComponent>
    )
  }
}

export default DeletePost

We are calling the DeleteOnePostComponent component that is autogenerated and we are calling our mutation when the button is clicked. Now that we have the component, we can use it in our FeedList component. Make sure that we import the DeletePost at the top of this file as well.

/frontend/components/feed.tsx

import DeletePost from './delete-post'
// lines omitted for brevity
//
{
  title: 'Action',
  key: 'action',
  render: ({ id }: { id: string }) => {
    return (
      <Button.Group>
        {published ? null : <PublishDraft id={id} />}
        <DeletePost id={id} />
      </Button.Group>
    );
  }
}

Now let's go to our website and we'll see that there is now a delete button. If we press the delete button, it should delete the post.

Before:

Before deleting a blog post entry.
Before deleting a blog post entry.

After:

After deleting a blog post entry.
After deleting a blog post entry.

So that's it! We've covered all the basics about building a full stack application using Prisma 2 with Next.js in Docker. This configuration is great for development and you shouldn't hesitate to hack on this and build out your own full stack web applications.

Productionize all the things!

For production however, you should make sure to copy your entire code into the docker containers so that you aren't relying on volume mounts for your source code. You'd also likely want to utilize a production ready MySQL instance that has automated backups, scaling, and fault tolerance such as AWS RDS which I've previously discussed how to hook up to Prisma 2. There are numerous other factors to consider when you get to that point though (such as automated deployment with a CI/CD pipeline), but luckily using Docker from the start provides a clear route for productionizing your application in the future.

Video Series for Part III:

Add Main Layout to Next.js

Add Users Component to Next.js

Add Signup User to Next.js

Add Feed Component to Next.js

Add New Blog Draft Component to Next.js

Add Publish Blog Component to Next.js

Add Delete Post Component to Next.js

Learn something new? Share it with the world!

There is more where that came from!

Drop your email in the box below and we'll let you know when we publish new stuff. We respect your email privacy, we will never spam you and you can unsubscribe anytime.