nextjs-server-actions

Server Actions in Next.js

  • 5 min

Until now, in any React application (and in older versions of Next.js), if we wanted a user to submit a form (for example, to register or create a post), we had to build a considerable infrastructure:

  1. Create an API endpoint on the backend (e.g., POST /api/create-user).
  2. On the frontend, create a state for each input (useState).
  3. Handle the onSubmit event.
  4. Make a fetch('/api/create-user') with the JSON body.
  5. Manage the response and errors.

In Next.js, much of this boilerplate disappears thanks to Server Actions.

Server Actions allow us to define asynchronous functions that run on the server but can be invoked directly from our components (even from the client), as if they were local JavaScript functions.

What is a Server Action?

Technically, it is a function marked with the 'use server' directive.

When Next.js sees this directive, it automatically creates an internal HTTP endpoint, serializes the arguments you pass to the function, and manages the network call for you. You only see a function call; Next.js sees a POST request.

Creating Our First Action

Let’s create a simple form to add a task (a Todo App, the hello world of SPAs).

For organizational purposes, it’s ideal to have the actions in a separate file, for example src/app/actions.ts.

// src/app/actions.ts
'use server' // 👈 This marks all exports as Server Actions

import db from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function createTodo(formData: FormData) {
  // 1. Extract the data from the native form
  const title = formData.get('title');

  if (!title || typeof title !== 'string') return;

  // 2. Database Operation (Safe, we are on the server)
  await db.todo.create({ data: { title } });

  // 3. IMPORTANT! Inform Next.js to update the view
  revalidatePath('/todos');
}

Copied!

Notice the use of FormData. Next.js promotes the use of web standards. We don’t need to create a React state for each input; the native HTML form already knows how to collect its data.

Connecting the Form

Now let’s go to our component (which could be a Server Component) and use the action.

// src/app/todos/page.tsx
import { createTodo } from '../actions';

export default async function Page() {
  const todos = await db.todo.findMany();

  return (
    <main>
      <h1>My Tasks</h1>
      
      {/* Task list */}
      <ul>
        {todos.map(t => <li key={t.id}>{t.title}</li>)}
      </ul>

      {/* Creation form */}
      {/* Pass the function DIRECTLY to the action prop */}
      <form action={createTodo} className="flex gap-2">
        <input 
          name="title" 
          type="text" 
          placeholder="New task..." 
          className="border p-2"
        />
        <button type="submit" className="bg-blue-500 text-white p-2">
          Add
        </button>
      </form>
    </main>
  );
}

Copied!

It’s quite clean, isn’t it? No useState, no onSubmit, no fetch. The form works even if the user has JavaScript disabled in their browser (Progressive Enhancement).

The revalidatePath Concept

In the previous example, I used revalidatePath('/todos'). It’s important to understand it well.

In a traditional SPA (Vite), when we created an item, we had to manually update the local tasks array (setTodos([...todos, newTodo])) so the user could see the change instantly.

In Next.js Server Components, the task list is static (HTML generated on the server). If we insert something into the database, the HTML the user is seeing is already “stale.”

By calling revalidatePath:

Next.js purges the cache for that specific route.

It re-executes the Server Component Page on the server.

It sends the new updated HTML to the browser.

The browser updates the list without reloading the entire page.

All of this happens in milliseconds and in a single round trip.

Visual Feedback: useFormStatus

The only problem with the previous example is that the user doesn’t know if the task is being submitted. Since we don’t have an isLoading state, how do we show a spinner or disable the button?

For this, we need a Client Component (because we need interactivity) and a special React DOM hook called useFormStatus.

Let’s extract the button into its own component:

// src/components/SubmitButton.tsx
'use client' // Required to use hooks

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  // This hook tells us if the parent form is being submitted
  const { pending } = useFormStatus();

  return (
    <button 
      type="submit" 
      disabled={pending}
      className="bg-blue-500 disabled:bg-gray-400 text-white p-2"
    >
      {pending ? 'Saving...' : 'Add'}
    </button>
  );
}

Copied!

And update our page:

import { SubmitButton } from '@/components/SubmitButton';

// ... inside the form ...
<form action={createTodo}>
  <input name="title" ... />
  {/* We use the client component here */}
  <SubmitButton /> 
</form>

Copied!

useFormStatus only works if the component using it is rendered inside the <form> element. It wouldn’t work if we used it in the Page component itself that contains the form.

Invocation from Client Components

Although the form example is the most common, you can also call Server Actions from an onClick in a Client Component.

'use client'
import { deleteTodo } from '../actions';

export function DeleteButton({ id }: { id: string }) {
  return (
    <button 
      onClick={async () => {
        // We can call the Server Action like a normal function
        await deleteTodo(id);
        alert('Deleted!');
      }}
    >
      Delete
    </button>
  );
}

Copied!

Next.js manages the bridge between that click in the browser and the function on your server.