Next.js Server Actions

24. May, 2025 16 min read Develop

The endpoint you never write

Most React codebases eventually grow a folder of tiny HTTP endpoints whose only job in life is to let one form write one row to a database. Each of them costs a route file, a fetch call, a JSON round trip, and the same data shape declared twice in TypeScript. Server Actions delete that folder. You write the function, you hand it to a form, and Next.js does the plumbing.

They landed as an alpha in Next.js 13.4 and were marked stable in 14, so at this point they are the default answer for mutations in the App Router rather than a thing you try out on a side project. If you read my notes on React 19, the hooks further down will look familiar. The form hooks and Server Actions were designed together and they only really make sense as a pair.

What a Server Action is

A Server Action is an async function marked with the 'use server' directive. It runs on the server, only on the server, and it can be called from a Server Component or a Client Component without you writing any transport code:

'use server';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.post.create({
    data: { title, content },
  });
}

Nothing in that file ever reaches the browser. What the client gets instead is a reference: an opaque identifier for the function, plus a bit of generated code that POSTs to it. Next.js serialises the arguments, sends them, runs the function, and serialises whatever comes back.

The second thing worth knowing is that Server Actions are wired into React’s transition machinery. Called from a form’s action prop, or wrapped in startTransition, they participate in concurrent rendering. That is what makes pending states and optimistic updates work without any bookkeeping on your side.

Compared with an API route

The usual way to write a create-post form before all this looked like the following. First the endpoint:

// app/api/posts/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function POST(request: Request) {
  const body = await request.json();
  const { title, content } = body;

  const post = await db.post.create({
    data: { title, content },
  });

  return NextResponse.json(post);
}

Then the form that talks to it, which has to become a Client Component because it needs an onSubmit handler:

// app/posts/new/page.tsx
'use client';

export default function NewPostPage() {
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const formData = new FormData(e.target as HTMLFormElement);

    await fetch('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: formData.get('title'),
        content: formData.get('content'),
      }),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" type="text" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  );
}

The same feature with a Server Action is two files that barely say anything:

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

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

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.post.create({
    data: { title, content },
  });

  revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from './actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" type="text" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  );
}

No fetch, no headers, no JSON, no 'use client'. The page stays a Server Component and the form keeps working with JavaScript disabled, because a <form action={fn}> degrades to an ordinary browser form post. That last property is the one I keep coming back to. Progressive enhancement used to be something you argued for in a planning meeting and then quietly dropped; here it is the default and you have to work to lose it.

Where the directive goes

There are two placements, and they are not interchangeable in practice.

The module-level version puts 'use server' at the top of a file and turns every export into an action:

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

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

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.post.create({
    data: { title, content },
  });

  revalidatePath('/posts');
}

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } });

  revalidatePath('/posts');
}

The inline version declares the action inside a Server Component:

// app/posts/page.tsx
export default function PostsPage() {
  async function handleDelete(formData: FormData) {
    'use server';
    const id = formData.get('id') as string;
    await db.post.delete({ where: { id } });
    revalidatePath('/posts');
  }

  return (
    <form action={handleDelete}>
      <input type="hidden" name="id" value="123" />
      <button type="submit">Delete</button>
    </form>
  );
}

I default to the file. It is easier to test, easier to grep for, and it keeps components readable. The inline form has one genuine advantage, though, which is that it closes over the render scope: if you need a snapshot of a value as it was when the page rendered, an inline action captures it for free. That convenience has a cost attached, and I come back to it in the security section.

Pending states with useFormStatus

A submit button that does nothing visible for two seconds is a bug report waiting to happen. useFormStatus from react-dom reads the state of the enclosing form:

// components/SubmitButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton({ label = 'Save' }: { label?: string }) {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Processing...' : label}
    </button>
  );
}
import { createPost } from './actions';
import { SubmitButton } from '@/components/SubmitButton';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" type="text" required />
      <textarea name="content" required />
      <SubmitButton label="Create Post" />
    </form>
  );
}

The hook has one rule that trips everybody up exactly once: it has to live in a component rendered inside the <form>, not in the component that renders the form. It reads from the nearest form context above it, so calling it alongside the <form> element gives you pending: false forever, with no warning and no error. That is why the button gets extracted into its own file even when it does nothing else.

Tracking the result with useActionState

useFormStatus tells you that something is happening. It does not tell you how it went. For that there is useActionState, which lives in react rather than react-dom and replaced the older useFormState:

// app/contact/page.tsx
'use client';

import { useActionState } from 'react';
import { sendMessage } from './actions';

const initialState = {
  success: false,
  message: '',
};

export default function ContactPage() {
  const [state, formAction, isPending] = useActionState(
    sendMessage,
    initialState
  );

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending...' : 'Send Message'}
      </button>
      {state.message && (
        <p className={state.success ? 'text-green' : 'text-red'}>
          {state.message}
        </p>
      )}
    </form>
  );
}

The action on the other side gains an extra first argument, the previous state:

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

export async function sendMessage(prevState: any, formData: FormData) {
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  try {
    await sendEmail({ to: email, body: message });

    return { success: true, message: 'Message sent successfully!' };
  } catch (error) {
    return { success: false, message: 'Failed to send message.' };
  }
}

You get back the current state, a wrapped action to hand to the form, and an isPending flag. Two small notes. The prevState: any in the signature above is what most examples show and it is worth replacing with a real type, because the return type of the action and the initial state have to agree and TypeScript will happily let them drift. And whatever you return crosses the network, so it has to be serialisable. A Date survives. A Prisma model instance with methods on it does not.

Optimistic updates

For interactions where waiting for the server feels absurd, useOptimistic lets you render the outcome you expect and roll it back if the server disagrees:

'use client';

import { useOptimistic, useTransition } from 'react';
import { toggleLike } from './actions';

type Post = {
  id: string;
  title: string;
  liked: boolean;
};

export function PostItem({ post }: { post: Post }) {
  const [isPending, startTransition] = useTransition();
  const [optimisticPost, setOptimisticPost] = useOptimistic(
    post,
    (currentPost, newLiked: boolean) => ({
      ...currentPost,
      liked: newLiked,
    })
  );

  const handleToggle = () => {
    startTransition(async () => {
      setOptimisticPost(!optimisticPost.liked);
      await toggleLike(post.id);
    });
  };

  return (
    <div>
      <h3>{optimisticPost.title}</h3>
      <button onClick={handleToggle} disabled={isPending}>
        {optimisticPost.liked ? 'Unlike' : 'Like'}
      </button>
    </div>
  );
}

The startTransition wrapper is not decoration. Outside a form’s action prop, it is the thing that ties the optimistic value to the lifetime of the server round trip. Drop it and the optimistic state reverts the moment the render finishes, which produces a button that flickers back to its old label for a few hundred milliseconds and looks broken. If the action throws, React discards the optimistic value and you are back at post.liked.

Revalidation and redirects

A mutation that does not invalidate its cache is a mutation that appears not to have happened. Next.js gives you two levers.

revalidatePath throws away everything cached for a URL. revalidateTag throws away every fetch that was tagged with a given string:

'use server';

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

export async function updatePost(formData: FormData) {
  const id = formData.get('id') as string;
  const title = formData.get('title') as string;

  await db.post.update({
    where: { id },
    data: { title },
  });

  // Blow away everything rendered for this path
  revalidatePath('/posts');

  // Or, more surgically, everything tagged 'posts'
  revalidateTag('posts');
}

Tags are the better instrument once an app has more than a handful of pages, since the same data usually appears in several places. To use them you tag the read side as well, with next: { tags: ['posts'] } on the fetch call. Paths are the blunt instrument you reach for when you are not yet sure which pages are affected, which in my experience is most of the time on a new project.

Redirecting after a mutation uses redirect from next/navigation:

'use server';

import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;

  const post = await db.post.create({
    data: { title },
  });

  redirect(`/posts/${post.id}`);
}

redirect works by throwing an internal NEXT_REDIRECT error, which means a try/catch around it will swallow the navigation and leave the user staring at the form they just submitted. Call it after the try/catch, never inside. If you need both, store the target in a variable in the try block and redirect once you are out.

Validating what comes in

Client-side validation is a courtesy to the user. It is not a check. Anyone can POST to the action directly, so the action validates or nothing does. Zod is the usual pairing:

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

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

const PostSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200),
  content: z.string().min(10, 'Content must be at least 10 characters'),
  category: z.enum(['tutorial', 'guide', 'opinion']),
});

export async function createPost(prevState: any, formData: FormData) {
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
    category: formData.get('category'),
  };

  const validated = PostSchema.safeParse(rawData);

  if (!validated.success) {
    return {
      success: false,
      errors: validated.error.flatten().fieldErrors,
    };
  }

  await db.post.create({ data: validated.data });

  revalidatePath('/posts');

  return { success: true, errors: null };
}

flatten().fieldErrors gives you an object keyed by field name with an array of messages under each, which maps straight onto the inputs. Combined with useActionState, that is a complete server-validated form with per-field errors and no client validation library at all.

One wrinkle: formData.get() returns null for a missing field, and null is not a string. If a checkbox is unchecked or a select never gets touched, your schema sees null and produces an error message about the wrong thing. Either give the schema explicit handling for it or normalise before parsing.

Errors

Returning errors as data beats throwing them, because a thrown error in an action becomes a generic message in production. Next.js deliberately hides the details, which is the correct behaviour and also mildly infuriating the first time you meet it:

'use server';

import { revalidatePath } from 'next/cache';

export async function processPayment(formData: FormData) {
  const amount = Number(formData.get('amount'));

  try {
    const result = await paymentService.charge(amount);

    revalidatePath('/billing');

    return { success: true, transactionId: result.id };
  } catch (error) {
    if (error instanceof PaymentError) {
      return { success: false, error: error.message };
    }

    return { success: false, error: 'An unexpected error occurred.' };
  }
}

Anything you do throw lands in the nearest error.tsx boundary. Be deliberate about what you put in a returned error string: database driver messages and upstream API responses leak schema names, table names and occasionally credentials. Log the real thing on the server, return the sentence the user can act on. I wrote about a related version of this problem in the Stripe post, where the payment provider’s error codes and the message you want to show a customer are almost never the same text.

Calling actions outside a form

Actions are ordinary functions from the caller’s point of view, so an event handler or an effect can invoke them:

'use client';

import { incrementViewCount } from './actions';
import { useEffect, useTransition } from 'react';

export function PostView({ postId }: { postId: string }) {
  const [, startTransition] = useTransition();

  useEffect(() => {
    startTransition(() => {
      incrementViewCount(postId);
    });
  }, [postId]);

  return null;
}
// actions.ts
'use server';

export async function incrementViewCount(postId: string) {
  await db.post.update({
    where: { id: postId },
    data: { views: { increment: 1 } },
  });
}

Worth remembering that every call is a POST. Actions dispatched from the same page are handled one after another rather than in parallel, so a component that fires one per item in a list will queue them up behind each other. Batch on the server instead of looping on the client.

What Next.js protects you from, and what it doesn’t

This is the part I would put first if the post were shorter, because it is the part that gets skipped. An exported Server Action is a public POST endpoint. It does not become one when you attach it to a form; it already is one.

Next.js does put real protection in the way:

Mechanism What it does
Encrypted action IDs The client references actions by a non-deterministic ID, recalculated between builds, not by function name
Dead code elimination An exported action that nothing in the app calls is stripped from the build and never gets a public ID
Origin/Host comparison Requests whose Origin header does not match Host (or X-Forwarded-Host) are rejected, which handles most CSRF
Closure encryption Variables an inline action closes over travel to the client and back, so Next.js encrypts them with a per-build key

That last row is the one to read twice. When you declare an action inside a component and it captures a variable, that variable is serialised into the payload the browser holds. It is encrypted, and the React team’s own advice is not to treat the encryption as a licence to close over secrets. Keep API keys and tokens out of the render scope of any component that declares an inline action.

If you self-host across several instances, each one generates its own encryption key at build time and actions created on one box fail on another. NEXT_SERVER_ACTIONS_ENCRYPTION_KEY pins it, base64-encoded, 32 bytes from openssl rand -base64 32. Behind a reverse proxy where the public domain differs from the internal host, the Origin check will also start rejecting legitimate traffic until you list the proxy in serverActions.allowedOrigins.

None of that is authorisation. The framework has no idea who is allowed to delete which post, so every action does its own check:

'use server';

import { auth } from '@/lib/auth';

export async function deletePost(id: string) {
  const session = await auth();

  if (!session?.user) {
    throw new Error('Unauthorized');
  }

  const post = await db.post.findUnique({ where: { id } });

  if (post?.authorId !== session.user.id) {
    throw new Error('Forbidden');
  }

  await db.post.delete({ where: { id } });

  revalidatePath('/posts');
}

Two checks, not one. Authentication asks whether there is a user; authorisation asks whether this user owns this row. A page-level guard covers neither, because the guard decides what renders and the action is a separate door into the same building. If you are picking a session library to hang off auth(), I went through the options in Better Auth.

Habits worth forming

Some of these I arrived at by reading the docs properly, others by getting them wrong first:

  1. One action, one job. Actions that create and email and audit are impossible to reuse and unpleasant to test.
  2. Keep the action thin and push the database work into a module marked import 'server-only'. The action becomes a validation and authorisation shell.
  3. Return only what the UI renders. A returned Prisma record ships every column to the browser, including the ones you forgot were there.
  4. Revalidate the narrowest thing that changed.
  5. Rate limit anything that sends mail or costs money.

Where I’ve landed

I like Server Actions more than I expected to. The thing that won me over was not the reduced boilerplate, it was that a form works before the JavaScript arrives and keeps working after it does, and I did not have to argue with anyone to get that.

What I am still uneasy about is how invisible the endpoint is. An API route is a file you can see, curl and reason about. An action is a function that quietly acquires a public URL, and the only thing standing between it and the internet is whatever check you remembered to write inside it. The framework has done a lot to make that safe by default. It cannot do the authorisation for you, and the ease of writing an action makes it very easy to forget that you owe it one

‘Till next time!