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 totrue
) 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 (at) gmail (dot) com