Server Actions are not just a way to run server-side code from your React components; they fundamentally change how you think about data mutations and client-server interaction in Next.js.

Let’s look at a typical Server Action in action. Imagine a simple form to add a new todo item:

// app/todos/page.jsx
import { addTodo } from './actions';

export default function TodosPage() {
  return (
    <form action={addTodo}>
      <input type="text" name="text" required />
      <button type="submit">Add Todo</button>
    </form>
  );
}

// app/todos/actions.js
'use server';

import { revalidatePath } from 'next/cache';

export async function addTodo(formData: FormData) {
  const text = formData.get('text') as string;
  // Here you would typically interact with a database
  console.log(`Adding todo: ${text}`);
  // Simulate a database write
  await new Promise(resolve => setTimeout(resolve, 500));

  revalidatePath('/todos'); // Invalidate the cache for the /todos page
}

When the form is submitted, the addTodo function executes on the server. Notice the 'use server' directive at the top of actions.js – this is what tells Next.js this is a Server Action. The FormData object is automatically passed, containing the input values. After performing some server-side logic (simulated here with a setTimeout), revalidatePath('/todos') is called. This is crucial: it tells Next.js that the data for the /todos page might have changed and that the cached version should be refreshed on the client’s next visit or interaction.

The real power of Server Actions comes from their ability to streamline data mutations without requiring explicit API routes for every simple form submission. They handle form data serialization, client-side form state management (like disabling buttons during submission), and revalidation automatically. This means you can often write all your UI and data mutation logic within your app directory, leading to a more cohesive codebase.

Under the hood, when you use a form with action={serverAction}, Next.js intercepts the form submission. It serializes the form data and sends it to a special Next.js server endpoint. This endpoint then invokes your serverAction function with the deserialized data. The response from the server action, including any data it returns or cache revalidation instructions, is then processed by the client-side Next.js runtime. This abstract away the complexities of HTTP requests, JSON parsing, and client-side loading states, making it feel almost like calling a local function.

One of the most overlooked aspects of Server Actions is their integration with Route Handlers. While Server Actions are great for form submissions and direct component-initiated mutations, you can also trigger Server Actions from within Route Handlers. This is particularly useful when you need to perform server-side mutations based on incoming API requests that don’t originate from a form. For example, you could have a POST request to /api/todos hit a Route Handler, which then calls your addTodo Server Action. The Route Handler would extract data from the request body, pass it to the Server Action, and then return a response based on the Server Action’s outcome. This allows for a unified way to handle data mutations across different entry points in your application.

A common gotcha arises when dealing with complex data types or nested objects in your form data. FormData is inherently designed for simple key-value pairs, often from traditional HTML forms. If you try to pass deeply nested objects or non-primitive types directly, you’ll encounter issues. The solution is to serialize complex data into JSON strings before putting them into FormData on the client, and then parse them back after retrieving them in the Server Action.

// Client-side form submission for complex data
async function handleSubmit(event) {
  event.preventDefault();
  const formData = new FormData(event.currentTarget);
  const complexData = {
    user: { id: 123, name: 'Alice' },
    settings: { theme: 'dark' }
  };
  formData.set('complexData', JSON.stringify(complexData));
  await addTodoWithComplexData(formData); // Assuming addTodoWithComplexData is a Server Action
}

// Server Action to handle complex data
'use server';

export async function addTodoWithComplexData(formData: FormData) {
  const complexDataString = formData.get('complexData') as string;
  const complexData = JSON.parse(complexDataString);
  console.log(complexData.user.name); // Output: Alice
  // ... rest of your logic
}

This pattern ensures that your complex data structures are transmitted correctly.

The next step in mastering Server Actions involves understanding their error handling and streaming capabilities for more dynamic user experiences.

Want structured learning?

Take the full Nextjs course →