Server actions are asynchronous functions that run on the server. Server actions were introduced to enable developers to run server-side code without the need for an API endpoint. Server actions are crucial as they can be used in both server and client components for a myriad of use cases, such as writing to a database, form submission, and data fetching to mutations.
Simplified data fetching: You can offload data fetching to the server, ensuring data consistency and preventing duplicate fetches.
Enhanced performance: Server actions can pre-render content on the server, reducing the amount of work done on the client side and improving perceived performance, especially for initial page loads.
More flexible logic: Server actions allow you to implement complex business logic on the server without needing separate API routes.
Integration with caching and revalidation: Server actions automatically benefit from Next.js's caching and revalidation mechanisms, ensuring optimal performance and data freshness.
Simplified form submissions: You can handle form submissions directly within your components using server actions, eliminating the need for separate API routes or complex client-side logic.
To really delve into what server actions let us accomplish, we will be creating an example project. This project is a comment form that takes some input, writes that input to our database, and then displays that data on the front end of our application. Let's get started by creating the form element in the code block below:
//src/app/page.tsx
export default async function Home() {
return (
<main className="flex min-h-screen items-center flex-col p-24 w-full bg-black text-white">
<h1 className="text-2xl">Comment form with Server Actions</h1>
<form
className="mt-4 flex flex-col w-[300px] my-16"
action={createTodoAction}
>
<input
className="border-2 border-gray-300 mb-5 rounded-md px-4 py-2 focus:outline-none focus:border-blue-500"
type="text"
name="username"
placeholder="Whats your username"
required
/>
<input
className="border-2 border-gray-300 mb-5 rounded-md px-4 py-2 focus:outline-none focus:border-blue-500"
type="email"
name="email"
placeholder="Enter your email address"
required
/>
<textarea
className="border-2 border-gray-300 mb-5 rounded-md px-4 py-2 focus:outline-none focus:border-blue-500"
name="text"
placeholder="Say whats on your mind"
required
/>
<button className="bg-blue-600 hover:bg-blue-900 text-white font-bold py-2 px-4 rounded">
Create Comment
</button>
</form
</main>
);
}
The code above creates a simple form element with an input field and a button. The result of the code should look like the image below:
Before the invention of server actions, when we needed to build a form that took some input and wrote that input to our database, we used the useState
hook to control our input field and send the input to an API route handler that would take the input, write it to the database, and then use the useEffect
hook to fetch the data from our database.
So, how do we do all this with server action? First, we create an actions.ts
file where we can create and easily export any created server action. Next, we create our server action as shown below:
//src/app/action.ts
"use server";
import { db } from "../db";
import { comments } from "../db/schema";
import { eq } from "drizzle-orm";
export async function createTodoAction(formData: FormData) {
const text = formData.get("text") as string;
const username = formData.get("username") as string;
const email = formData.get("email") as string;
await db.insert(comments).values({
username,
email,
text,
});
}
In the code block above, we created a function that gets the values of the form data, which has a key of text
, username
, and email
, and then saves them to the database.
It is important to note that we used text
, username
, and email
as the keys in this particular example because the form element that we created above has three inputs, which we named text
, username
, and email
. So make sure that the name you give your input is the same name that you use as the key when trying to get the value.
Another thing to note is that I'm running a Postgres database locally and using Drizzle ORM to connect to it, but you can use Prisma, Mongoose, or whatever works for you.
With that done, we will connect our created server action to our form element by importing it and using it as shown below:
//src/app/page.tsx
import { createCommentAction } from "./actions";
export default async function Home() {
return (
<main className="flex min-h-screen items-center flex-col p-24 w-full bg-black text-white">
{/* Code here remains the same */}
<form
className="mt-4 flex flex-col w-[300px] my-16"
action={createCommentAction}
>
{/* Code here remains the same */}
</form>
</main>
);
}
The next step is to fetch the data that has been saved in the database and display it on the application. Let's do that below:
//src/app/page.tsx
import Image from "next/image";
import { db } from "../db";
import { createCommentAction } from "./actions";
import CommentCard from "./CommentCard";
export default async function Home() {
const allComments = await db.query.comments.findMany();
return (
<main className="flex min-h-screen items-center flex-col p-24 w-full bg-black text-white">
{/* Code for the form element remains the same */}
{allComments.length === 0 ? (
<h3>No Comments available</h3>
) : (
<>
<h1 className="mb-3">
{allComments.length}{" "}
{allComments.length === 1 ? "comment" : "comments"} available
</h1>
<div className="max-w-md mx-auto bg-white shadow-md rounded-md p-6">
<ul className="list-disc">
{allComments.map((comment) => (
<CommentCard
key={comment.id}
id={comment.id}
username={comment.username}
email={comment.email}
text={comment.text}
/>
))}
</ul>
</div>
</>
)}
</main>
);
}
In the code above, we are fetching the data from our database and mapping it to a card component that we created to showcase the comments.
Here is the code for the card component:
//src/app/CommentCard.tsx
import React from "react";
interface CommentCardProps {
id: number;
username: string;
email: string;
text: string;
}
export default function CommentCard({
id,
username,
email,
text,
}: CommentCardProps) {
return (
<>
<li className="flex gap-4 items-center">
<div>
<h3 className=" text-sm mb-4 font-semibold ">
{username} ({email}) commented:
</h3>
<p className="text-gray-700">{text}</p>
</div>
<button className="text-red-400">Delete</button>
</li>
</>
);
}
Now that we have everything set up, let's check the results on the browser. When the form is filled and submitted, nothing happens. The data is displayed on the application, and nothing seems to work, as shown below:
However, when we refresh the page on the browser, we see that the comment was saved as shown below:
This is happening because our page is cached, and when the form is submitted, we need to tell Next to revalidate the page and fetch new data. Let's do that below in our actions file:
//src/app/action.ts
"use server";
import { db } from "../db";
import { comments } from "../db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
export async function createCommentAction(formData: FormData) {
const text = formData.get("text") as string;
const username = formData.get("username") as string;
const email = formData.get("email") as string;
await db.insert(comments).values({
username,
email,
text,
});
revalidatePath("/");
}
In the code above, we import revalidatePath
from Next, call it after our data mutation, and pass the page that we want to revalidate, which in this case is the index page. Now, when we create a new comment, we see it immediately.
It is always a good idea to have some form of indication when submitting a form to enable a good user experience. You can do that by using the useFormStatus
hook in React. This hook returns the state of the form element, and it must be used within a form element.
To use hooks, we need to change our component to a Client component, but instead of changing the entire form component to a Client component, we will make the button a Client component and then import it into our form. Let's do that below:
//src/app/Button.tsx
"use client";
import { useFormStatus } from "react-dom";
export default function Button() {
const { pending } = useFormStatus();
return (
<button
className="bg-blue-600 hover:bg-blue-900 text-white font-bold py-2 px-4 rounded"
disabled={pending}
>
{pending ? "Creating Comment" : "Create Comment"}
</button>
);
}
In the code block above, we created a button component. We imported the useFormStatus
hook and then accessed the form's pending state. We can show any indicator we want using the pending state, and in this case, we disabled the button and showed a different text.
We need to be able to delete comments from our comments form, and we already have a button for that, so let's see how we can use Server Actions to do this.
Let's create the Server Action for this in our action file:
"use server";
import { db } from "../db";
import { comments } from "../db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
// The createCommentAction remains the same
export async function deleteCommentAction(id: number) {
await db.delete(comments).where(eq(comments.id, id));
revalidatePath("/");
}
We are accessing the id
of the comment, deleting the comment with that id from our database, and then calling revalidatePath
to ensure we don't have stale data.
Next, we need to wrap our delete button in a form so that we can connect our newly created Server Action to it, as shown below:
import React from "react";
import { deleteCommentAction } from "./actions";
interface CommentCardProps {
id: number;
username: string;
email: string;
text: string;
}
export default function CommentCard({
id,
username,
email,
text,
}: CommentCardProps) {
return (
<>
<li className="max-w-md flex gap-6 mx-auto bg-white shadow-md rounded-md mb-4 p-6">
{/* The code here remains the same */}
<form action={deleteCommentAction.bind(null, id)}>
<button className="text-red-400">Delete</button>
</form>
</li>
</>
);
}
We connected the Server Action to the button, and we used the JavaScript binding
method to pass additional arguments to a Server Action, which, in this case, is the ID
of the comment that we want to delete. With that done, our delete button now works as shown below:
Server Actions have emerged as a transformative tool in the Next.js ecosystem, empowering developers to craft secure, performant, and dynamic web applications.
Whether you're building a data-intensive application or prioritizing security, Server Actions deserve a place in your Next.js toolkit.