Skip to content
C Codeloom
Next.js

Next.js Server Actions Explained

Use Next.js server actions to handle form submissions and mutations without API routes. Covers use server, revalidation, and security considerations.

·6 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • How the "use server" directive turns a function into a callable endpoint
  • Submitting forms without writing a fetch call
  • Performing mutations and revalidating cached data
  • Returning typed results and surfacing validation errors
  • Security pitfalls and how to avoid them

Prerequisites

  • Working knowledge of [Next.js Server Components](/blog/nextjs-server-components)
  • Familiarity with [Next.js data fetching patterns](/blog/nextjs-data-fetching)

Server actions let you write a function that runs on the server and call it directly from a component as if it were local. Next.js handles the network plumbing, serialization, and CSRF token automatically. The result is far less ceremony for the common case of “user submits a form, server writes to a database, page revalidates.”

The “use server” directive

A function becomes a server action when its body starts with the string "use server", or when it lives in a file whose first line is "use server". The directive tells the bundler to keep the implementation on the server and replace the client-side reference with a generated RPC stub.

// app/actions.ts
'use server';

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

export async function createTodo(formData: FormData) {
  const title = String(formData.get('title') ?? '').trim();
  if (!title) return { ok: false, error: 'Title is required' };

  await db.todo.create({ data: { title } });
  revalidatePath('/todos');
  return { ok: true };
}

Any component, server or client, can now import createTodo and call it. The function never ships to the browser.

Wiring an action into a form

The simplest binding is to pass the action directly to a <form> element’s action prop. The browser submits the form, Next.js invokes the server action with a FormData object, and you handle the rest.

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

export default function TodosPage() {
  return (
    <form action={createTodo}>
      <input name="title" placeholder="What needs doing?" required />
      <button type="submit">Add</button>
    </form>
  );
}

No fetch, no API route, no JSON encoding. This works even with JavaScript disabled because it falls back to a regular HTML form post.

Mutations and revalidation

Most actions write something, then need to refresh the data the page is showing. Next.js gives you revalidatePath and revalidateTag to invalidate cached fetches. Call them after a successful mutation.

'use server';

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

export async function toggleTodo(id: string) {
  const todo = await db.todo.findUnique({ where: { id } });
  if (!todo) return { ok: false, error: 'Not found' };

  await db.todo.update({
    where: { id },
    data: { completed: !todo.completed },
  });

  revalidateTag(`todo:${id}`);
  revalidatePath('/todos');
  return { ok: true };
}

revalidatePath clears the cache for an entire route, while revalidateTag is more surgical and only clears fetches that were tagged with that string. Use tags when you have a long list and only want to refresh one row.

Returning state to the client

When you need to display validation errors or confirmation messages, pair your action with the useActionState hook in a client component. The hook tracks the previous result and re-renders when the action completes.

// app/todos/NewTodoForm.tsx
'use client';

import { useActionState } from 'react';
import { createTodo } from '@/app/actions';

type State = { ok: boolean; error?: string };
const initial: State = { ok: false };

export function NewTodoForm() {
  const [state, formAction, pending] = useActionState(createTodo, initial);

  return (
    <form action={formAction}>
      <input name="title" required />
      <button type="submit" disabled={pending}>
        {pending ? 'Saving...' : 'Add'}
      </button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

Note that the action signature changes slightly when you use useActionState. The first argument becomes the previous state, and the second is the form data.

'use server';

export async function createTodo(_prev: State, formData: FormData) {
  const title = String(formData.get('title') ?? '').trim();
  if (!title) return { ok: false, error: 'Title is required' };
  // ...write to db, revalidate...
  return { ok: true };
}

Calling actions outside forms

Server actions are just functions. You can call them from onClick, from useEffect, or from another server action. There is no requirement to use a form.

'use client';

import { toggleTodo } from '@/app/actions';
import { useTransition } from 'react';

export function TodoRow({ id, completed }: { id: string; completed: boolean }) {
  const [pending, startTransition] = useTransition();

  return (
    <button
      disabled={pending}
      onClick={() => startTransition(() => toggleTodo(id))}
    >
      {completed ? 'Undo' : 'Done'}
    </button>
  );
}

useTransition keeps the UI responsive while the action runs in the background.

Security notes

Server actions are public endpoints. Anyone can discover the generated action ID and post arbitrary data to it. Treat them with the same rigor you would treat a REST handler.

  • Always re-check authentication and authorization inside the action. Do not assume the caller is logged in because the form was rendered on a private page.
  • Validate every field. Use a schema library so you get type-safe, runtime-checked input rather than trusting FormData directly.
  • Be careful with closures. If you capture a variable from the surrounding scope and the bundler sends it as part of the action’s signature, it is visible to the client. Keep secrets in environment variables and read them inside the action body.
  • Do not return database objects directly if they contain fields the user should not see. Shape the response.
'use server';

import { z } from 'zod';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const schema = z.object({ title: z.string().min(1).max(140) });

export async function createTodo(_prev: unknown, formData: FormData) {
  const session = await auth();
  if (!session) return { ok: false, error: 'Not signed in' };

  const parsed = schema.safeParse({ title: formData.get('title') });
  if (!parsed.success) return { ok: false, error: 'Invalid input' };

  await db.todo.create({
    data: { title: parsed.data.title, userId: session.userId },
  });
  return { ok: true };
}

When not to use server actions

Server actions shine for user-initiated mutations. They are less useful for:

  • Cron-style background jobs. Use a queue or scheduled function.
  • Webhooks called by third parties. Use a route handler so you can verify signatures cleanly.
  • Read-heavy endpoints that need fine-grained caching. Server components plus tagged fetch calls handle that better, as covered in Next.js data fetching.

Wrap up

Server actions collapse the form-to-database round trip into a single function call. You write less code, ship less JavaScript, and get progressive enhancement for free. Pair them with useActionState for friendly error UX, revalidatePath or revalidateTag for cache freshness, and a real validation library for safety. Once they click, you will reach for an API route only when you actually need one.