Luciano Serruya Aloisi

Software developer from Trelew, Chubut, Argentina 🇦🇷

Building a full-stack app with Next.js - CRUD operations

Hello there! This is the third part of a series about building a full-stack app with Next.js. After the second article, we had an app that fetches and displays posts from a local database, and we deployed our app to Cloud Run (GCP) and the database to RDS (AWS). In this article, we are going to add a form to add and edit posts, and a button to delete them

Adding a create page

This should be straightforward. We already know how the routing mechanism works in Next.js (file-based routing). We now want a route such as post/new that displays a form to add a new post whenever we visit it. Create a file named new.tsx inside pages/post and add the following content

import { useState, FormEvent } from "react";

type FormData = {
  title: string;
  body: string;
};
function AddNewPage() {
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");

  function isValid(data: FormData): boolean {
    return data.body !== "" && data.title !== "";
  }

  function onFormSubmit(event: FormEvent<HTMLFormElement>, data: FormData) {
    event.preventDefault();
    alert(`Submitting: ${data.title} - ${data.body}`);
}

  return (
    <section className="m-4">
      <form
        className="bg-white px-8 pt-6 pb-8 mb-4"
        onSubmit={e => onFormSubmit(e, { title, body })}
      >
        <div className="mb-4">
          <label
            className="block text-gray-700 text-sm font-bold mb-2"
            htmlFor="title"
          >
            Title
          </label>
          <input
            className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            id="title"
            type="text"
            placeholder="Your title"
            value={title}
            onChange={e => setTitle(e.target.value)}
            required
          />
        </div>
        <div className="mb-6">
          <label
            className="block text-gray-700 text-sm font-bold mb-2"
            htmlFor="body"
          >
            Body
          </label>
          <textarea
            className="shadow appearance-none rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
            id="body"
            rows={10}
            placeholder="Your body"
            value={body}
            onChange={e => setBody(e.target.value)}
            required
          />
        </div>

        <div className="flex md:justify-end">
          <button
            className={`bg-blue-500 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline flex-grow md:flex-grow-0 ${
              !isValid({ title, body })
                ? "opacity-50 cursor-not-allowed"
                : "hover:bg-blue-700"
            }`}
            disabled={!isValid({ title, body })}
            type="submit"
          >
            Submit
          </button>
        </div>
      </form>
    </section>
  );
}
export default AddNewPage;

(it's a lot of code)

Our post/new page displays a form with a Submit button that will be disabled if the form is invalid (it will valid once every input field has some content in it). When you submit the form, an alert saying Submitting: <YOUR_TITLE> - <YOUR_BODY> will be displayed. It's a really basic form, but will work for our use case. What we need to do now is send our new post to the server so we can store it in the database. For that, we will add a API endpoint to handle the creation of a new post.

Next.js allows us to add back-end code out-of-the-box, just by creating an api directory inside our pages directory. Routing system works the same way as with pages - each file will represent a different API endpoint (same behavior applies for dynamic routing as well).

Create a directory named api inside pages/, and then a post directory inside api/. Finally, create a file with the name index.ts inside pages/api/post and add the following code

import { NextApiRequest, NextApiResponse } from "next";
import { Post } from "../../../models/post.model";
import { getOrCreateConnection } from "../../../utils";
import { NewPostFormData } from "../../post/new";

export default async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === "POST") {
    try {
      const data = req.body as NewPostFormData;

      const conn = await getOrCreateConnection();
      const postRepo = conn.getRepository<Post>("Post");

      const post = new Post();
      post.body = data.body;
      post.title = data.title;
      post.id = (await postRepo.count()) + 1;
      const newPost = await postRepo.save(post);

      return res.status(201).json({ status: "Success", data: newPost });
    } catch (error) {
      return res.status(500).json({
        status: "Error",
        data: { msg: "Could not create post", error }
      });
    }
  }

  return res.status(405).json({ msg: "Method not implemented" });
};

The post/new endpoint will only accept POST requests, and return a HTTP status code 405 otherwise. If the incoming request is a POST, it'll retrieve the data from the body and treat it as NewPostFormData. Then, it'll get a repo (same thing we are doing in the index page), create a new Post object and save it. If everything goes well, it will return a HTTP status code 201 and a status object to indicate that a new Post was created successfully. If not, it will return a HTTP status code 500 saying that could not create a new post. We can test manually this endpoint using cURL

curl http://localhost:3000/api/post -X POST -H "Content-Type: application/json" --data '{"title": "My new post", "body": "This a new post!"}'

Okay, now we have our form and we have the logic to create a new Post, but we haven't connected both of them yet, so let's do that.

We already have a function that handles the event when form is submitted, but it is only displaying a message. We need to update it to it hits our back-end to create a new post

// pages/post/new.tsx

async function sendData(data: NewPostFormData) {
  const res = await fetch("/api/post", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify(data)
  });

  const body = await res.json();
  if (!res.ok) {
    throw new Error(body.data.error.message);
  }
}

function AddNewPage() {
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");

  function isValid(data: NewPostFormData): boolean {
    return data.body !== "" && data.title !== "";
  }
  function reset() {
    setTitle("");
    setBody("");
  }

  async function onFormSubmit(
    event: FormEvent<HTMLFormElement>,
    data: NewPostFormData
  ) {
    event.preventDefault();
    try {
      sendData(data);
      alert("Post created successfully!");
      reset();
    } catch (error) {
      alert("Something went wrong :/");
    }
  }

  return (...)

We just updated our onFormSubmit function and added two new ones: reset and sendData. onFormSubmit nows calls sendData with form data, and if it completes successfully displays a message via an alert, otherwise display a message saying that the post could not be created. sendData uses the fetch API to send a POST request to our back-end; unfortunately fetch does not throw an exception if the server responded with a HTTP status code 4xx or 5xx, so we need to handle that logic ourselves. Finally, reset simply resets the input fields. We can now create a new post using our form, go to our index page and see it is there, great!

Adding an edit page

Having our add page already completed, we now want to edit a post. To do so, we will create an edit page that will be rendered when we append /edit/ to a post detail route. For example, if we visit post/123/edit, our app will display an edit page to edit post 123. We will need to move some of our files around to make this work, so let's do some hacking.

First of all, create a new directory called [id] inside pages/post. Move pages/post/[id].tsx to pages/post/[id] and rename it to index.tsx

// Before
- pages/
   - post/
      - [id].tsx
// After
- pages/
   - post/
      - [id]/
         - index.tsx

You will need to restart your development server after making these changes, and update some imports in pages/post/[id]/index.tsx. Everything should work properly after that.

Great. Now create a edit.tsx file inside pages/post/[id]. This will be the page we want to visit whenever we navigate to post/<SOME_ID>/edit. This new page should display a form to update the post, and we already have such a form! But, instead of copying the same code to this new file, we should refactor it to a separate component and use it in both pages/post/new and pages/post/[id]/edit.

// components/EditPostForm.tsx

import { Post } from "../models/post.model";
import { useState, FormEvent } from "react";

export type EditPostFormData = {
  title: string;
  body: string;
};

type Props = {
  onSubmit: (data: EditPostFormData) => void;
  post?: Post;
  reset?: boolean;
};

const EditPostForm: React.FC<Props> = ({ onSubmit, post, reset }) => {
  const [title, setTitle] = useState(post?.title || "");
  const [body, setBody] = useState(post?.body || "");
  function doReset() {
    setTitle("");
    setBody("");
  }
  function isValid(data: EditPostFormData): boolean {
    return data.body !== "" && data.title !== "";
  }
  function onFormSubmit(e: FormEvent<HTMLFormElement>, data: EditPostFormData) {
    e.preventDefault();
    onSubmit(data);
    if (reset) doReset();
  }

  return (
    <form
      className="bg-white px-8 pt-6 pb-8 mb-4"
      onSubmit={e => onFormSubmit(e, { title, body })}
    >
      <div className="mb-4">
        <label
          className="block text-gray-700 text-sm font-bold mb-2"
          htmlFor="title"
        >
          Title
        </label>
        <input
          className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
          id="title"
          type="text"
          placeholder="Your title"
          value={title}
          onChange={e => setTitle(e.target.value)}
          required
        />
      </div>
      <div className="mb-6">
        <label
          className="block text-gray-700 text-sm font-bold mb-2"
          htmlFor="body"
        >
          Body
        </label>
        <textarea
          className="shadow appearance-none rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
          id="body"
          rows={10}
          placeholder="Your body"
          value={body}
          onChange={e => setBody(e.target.value)}
          required
        />
      </div>

      <div className="flex md:justify-end">
        <button
          className={`bg-blue-500 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline flex-grow md:flex-grow-0 ${
            !isValid({ title, body })
              ? "opacity-50 cursor-not-allowed"
              : "hover:bg-blue-700"
          }`}
          disabled={!isValid({ title, body })}
          type="submit"
        >
          Submit
        </button>
      </div>
    </form>
  );
};

EditPostForm.defaultProps = { reset: true };
export { EditPostForm };

It is the exact same form, but now it lives in a different file as a React component. It takes three props

  • a function which will be called when the form is submitted
  • a Post object that, in case of having one, will be used to populate our form input fields
  • a boolean flag called reset (defaults to true) to indicate if the form input fields should be reset once submitted

pages/post/new.tsx is now quite smaller now

// pages/post/new.tsx

import { EditPostForm } from "../../components/EditPostForm";

export type NewPostFormData = {
  title: string;
  body: string;
};

async function sendData(data: NewPostFormData) {
  const res = await fetch("/api/post", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify(data)
  });

  const body = await res.json();
  if (!res.ok) {
    throw new Error(body.data.error.message);
  }
}

function AddNewPage() {
  async function onFormSubmit(data: NewPostFormData) {
    try {
      sendData(data);
      alert("Post created successfully!");
    } catch (error) {
      alert("Something went wrong :/");
    }
  }

  return (
    <section className="m-4">
      <EditPostForm onSubmit={onFormSubmit} />
    </section>
  );
}

export default AddNewPage;

Our pages/post/[id]/edit.tsx should look like this

// pages/post/[id]/edit.tsx

import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import { getOrCreateConnection } from "../../../utils";
import { Post } from "../../../models/post.model";
import {
  EditPostForm,
  EditPostFormData
} from "../../../components/EditPostForm";
import { useRouter } from "next/router";

export async function getServerSideProps(context: GetServerSidePropsContext) {
  const { id } = context.params;
  const conn = await getOrCreateConnection();
  const postRepo = conn.getRepository<Post>("Post");
  const post = JSON.stringify(
    await postRepo.findOneOrFail(parseInt(id as string))
  );
  return {
    props: { post }
  };
}

async function sendData(id: number, data: EditPostFormData) {
  const res = await fetch(`/api/post/${id}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify(data)
  });

  const body = await res.json();
  if (!res.ok) {
    throw new Error(body.data.error.message);
  }
}

export default function EditPostPage({
  post
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  const postObj = JSON.parse(post) as Post;
  const router = useRouter();
  function handleSubmit(data: EditPostFormData) {
    try {
      sendData(postObj.id, data);
      alert("Post updated successfully!");
      router.replace(`/post/${postObj.id}`);
    } catch (error) {
      alert("Something went wrong :/");
    }
  }

  return (
    <section className="m-4">
      <EditPostForm onSubmit={handleSubmit} post={postObj} reset={false} />
    </section>
  );
}

getServerSideProps is doing the exact same thing as in pages/post/[id]/index.ts - we are fetching the post once again. The page itself only renders EditPostForm passing it as props the Post object, and a function to handle the submit event. sendData sends a PUT request to api/post/[id] with the specified data, but with don't have that route yet.

BEFORE CONTINUING is important to note that, as we move some type definitions around and renamed them, is important to update any imports we were doing. For example, we were importing NewPostFormData in pages/api/post/index.ts.

To define our new API endpoint, we need to create the corresponding files. Create a new file called [id].ts inside pages/api/post and add the following content

import { NextApiRequest, NextApiResponse } from "next";
import { EditPostFormData } from "../../../components/EditPostForm";
import { getOrCreateConnection } from "../../../utils";
import { Post } from "../../../models/post.model";

export default async (req: NextApiRequest, res: NextApiResponse) => {
  const {
    query: { id }
  } = req;
 if (req.method === "PUT") {
    const data = req.body as EditPostFormData;

    const conn = await getOrCreateConnection();
    const postRepo = conn.getRepository<Post>("Post");

    await postRepo.update({ id: parseInt(id as string) }, data);
    return res.status(200).json({ status: "Success", data });
  }

  return res.status(405).json({ msg: "Method not implemented" });
};

Awesome, we can now create and edit posts, but we can delete a post yet. Also, there are now links to navigate to the add page or the edit page.

Deleting a post

To delete a post, we will simply add a button right at the bottom of our post detail page. When the user clicks on it, we will display a message to confirm if they really want to delete it, and if so hit our back-end. Update your pages/post/[id]/index.tsx so it looks like the following

// pages/post/[id]/index.tsx

import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import { getOrCreateConnection } from "../../../utils";
import { Post } from "../../../models/post.model";
import { useRouter } from "next/router";

export async function getServerSideProps(context: GetServerSidePropsContext) {
  const { id } = context.params;
  const conn = await getOrCreateConnection();
  const postRepo = conn.getRepository<Post>("Post");
  const post = JSON.stringify(
    await postRepo.findOneOrFail(parseInt(id as string))
  );
  return {
    props: { post }
  };
}

async function deletePost(id: number) {
  const res = await fetch(`/api/post/${id}`, {
    method: "DELETE"
  });
  if (!res.ok) {
    const body = await res.json();
    throw new Error(body.data.error.message);
  }
}

export default function PostDetailPage({
  post
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  const postObj = JSON.parse(post) as Post;
  const router = useRouter();
  async function handleDeleteButtonClick(id: number) {
    const answer = confirm("Are you sure you want to delete this post?");
    if (!answer) return;

    try {
      await deletePost(id);
      alert("Post deleted successfully!");
      router.replace("/");
    } catch (error) {
      alert("Something went wrong :/");
    }
  }
  return (
    <section className="m-4">
      <h1 className="m-4 text-center text-3xl text-red-400">{postObj.title}</h1>
      <p className="m-8">{postObj.body}</p>
      <div className="flex justify-end">
        <button
          onClick={() => handleDeleteButtonClick(postObj.id)}
          className="bg-red-500 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline flex-grow md:flex-grow-0"
        >
          Delete
        </button>
      </div>
    </section>
  );
}

We are using the confirm function to display a simple confirmation dialog and then delete the post if the user confirms. If you try this code, it will fail. We are send a DELETE request to our back-end (to the api/post/[id] endpoint), but we don't currently support such a method. Update your pages/api/post/[id].ts with the following code

import { NextApiRequest, NextApiResponse } from "next";
import { EditPostFormData } from "../../../components/EditPostForm";
import { getOrCreateConnection } from "../../../utils";
import { Post } from "../../../models/post.model";

export default async (req: NextApiRequest, res: NextApiResponse) => {
  const {
    query: { id }
  } = req;
  if (req.method === "DELETE") {
    const conn = await getOrCreateConnection();
    const postRepo = conn.getRepository<Post>("Post");

    await postRepo.delete({ id: parseInt(id as string) });
    return res.status(204).end();
  } else if (req.method === "PUT") {...}

  return res.status(405).json({ msg: "Method not implemented" });
};

Adding links

Now that we have a delete button, we might as well add a edit button, right? Update once again your PostDetailPage component to return the following markup

export default function PostDetailPage({
  post
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  .
  .
  .
  return (
    <section className="m-4">
      <h1 className="m-4 text-center text-3xl text-red-400">{postObj.title}</h1>
      <p className="">{postObj.body}</p>
      <div className="mt-20 flex flex-col md:flex-row md:justify-end">
        <button className="bg-blue-500 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline block flex-grow md:inline md:flex-grow-0">
          <a href={`/post/${postObj.id}/edit`}>Edit</a>
        </button>
        <button
          onClick={() => handleDeleteButtonClick(postObj.id)}
          className="bg-red-500 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline block flex-grow mt-2 md:inline md:flex-grow-0 md:m-0 md:ml-1"
        >
          Delete
        </button>
      </div>
    </section>
  );
}

Before finishing this part of the series, let's add a link in the navbar to go to the add page. Open your _app.tsx file and put the following markup inside the <nav> tag

          <ul className="flex justify-between items-center p-8 bg-blue-100">
            <li>
              <a href="/" className="text-blue-500 no-underline">
                Home
              </a>

              <a href="/about" className="text-blue-500 no-underline p-8">
                About
              </a>
            </li>
            <ul className="flex justify-between items-center space-x-4">
              <li>
                <a
                  href="/post/new"
                  className="bg-blue-500 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline no-underline"
                >
                  Add
                </a>
              </li>
            </ul>
          </ul>

Deployment

We can now app, edit, and delete posts through our app, awesome! Before wrapping our this article, let's deploy our app. In the last article we added two really handy npm-scripts to build and deploy our app

npm run gcp:build && npm run gcp:deploy

Hope you liked it!

🐦 @LucianoSerruya

📧 lucianoserruya (at) gmail (dot) com