Server Actions in Nextjs

Isaac Okoro
August 14, 2024|
5 min read
Mountain

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.


Why use Server Actions


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.

 

What does Server action let you do


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:


comment form


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:


form


However, when we refresh the page on the browser, we see that the comment was saved as shown below:


form


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.


Showing Pending State

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.


Deleting a Comment

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:


form


Conclusion

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.