DevLift
Back to Blog

Next.js Server Actions: Mutations Without the API Route Tax

Server Actions collapse form handling into a single server function—no API route, no manual fetch, no useState for loading. Here's the pattern that replaces 80% of your mutation boilerplate.

Admin
August 3, 20267 min read5 views

Next.js Server Actions: Mutations Without the API Route Tax

Here's a flow that most Next.js apps have lived in for years: user fills out a form, client component calls fetch('/api/some-endpoint', { method: 'POST', body: JSON.stringify(data) }), API route validates, mutates the database, returns JSON, client updates state based on the response. Then you add loading state, error state, and a success state, wire them up with useState, and realize you've now written three files for what is conceptually one operation.

Server Actions collapse that down. The form submits directly to a function that runs on the server. No API route. No fetch. No manual state plumbing for loading/error — that's what useActionState is for. And as a bonus, the form works even before JavaScript loads, which is a thing developers stopped expecting years ago but is nice to have again.

This isn't magic. It's RPC over HTTP with progressive enhancement baked in. Once you understand the execution model, the pattern becomes obvious — and you'll stop reaching for API routes for mutations.

The Old Way (So We Know What We're Replacing)

A typical mutation in the old model:

// app/api/posts/route.ts
export async function POST(request: Request) {
  const body = await request.json();
  const { title, content } = body;
 
  if (!title || !content) {
    return Response.json({ error: 'Title and content required' }, { status: 400 });
  }
 
  const post = await db.post.create({ data: { title, content } });
  return Response.json({ post }, { status: 201 });
}
 
// components/CreatePostForm.tsx
'use client';
 
import { useState } from 'react';
 
export function CreatePostForm() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
 
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setLoading(true);
    setError(null);
 
    const formData = new FormData(e.currentTarget);
 
    try {
      const res = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          title: formData.get('title'),
          content: formData.get('content'),
        }),
      });
 
      if (!res.ok) {
        const data = await res.json();
        setError(data.error);
        return;
      }
 
      // redirect, invalidate cache, whatever
    } catch {
      setError('Something went wrong');
    } finally {
      setLoading(false);
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      {error && <p className="text-red-500">{error}</p>}
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit" disabled={loading}>
        {loading ? 'Saving...' : 'Save'}
      </button>
    </form>
  );
}

That's a lot of boilerplate for "save a post." And it's all client-side state that the framework can't help you with. Server Actions can.

The Pattern

Server Actions are async functions marked with 'use server'. When you pass one to a form's action prop, React wires it up so the form submits to that function directly — the FormData lands in the function's first argument, server-side.

// actions/posts.ts
'use server';
 
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';
 
export async function createPost(formData: FormData) {
  // First line of every action. This is a public POST endpoint — see below.
  const session = await getSession();
  if (!session?.user) redirect('/sign-in');
 
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
 
  await db.post.create({ data: { title, content } });
 
  revalidatePath('/posts');
  redirect('/posts');
}
// app/posts/new/page.tsx — this is a Server Component
import { createPost } from '@/actions/posts';
 
export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Save</button>
    </form>
  );
}

No 'use client'. No useState. No fetch. The form action receives the function reference, React handles the POST, and revalidatePath busts the cache on the posts listing so the new post shows up. That's the whole thing.

💡

The action runs on the server even when JavaScript is available. The only difference JavaScript makes is whether the submission is a full page navigation (no JS) or a background request with the page staying interactive (with JS).

Adding State: useActionState

The basic pattern works for simple cases. But most forms need to show validation errors, keep the user's input if validation fails, or display a success state. That's useActionState.

The hook takes your action function (with a modified signature that includes prevState) and an initial state. It returns [state, formAction, isPending].

First, update the action to return state instead of redirecting on error:

// actions/posts.ts
'use server';
 
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';
 
const PostSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200, 'Title too long'),
  content: z.string().min(10, 'Content must be at least 10 characters'),
});
 
export type PostActionState = {
  errors?: {
    title?: string[];
    content?: string[];
  };
  message?: string;
};
 
export async function createPost(
  prevState: PostActionState,
  formData: FormData,
): Promise<PostActionState> {
  // Authorize before you look at the payload. A caller who never rendered your
  // form can still POST here.
  const session = await getSession();
  if (!session?.user) {
    return { message: 'You must be signed in to publish.' };
  }
 
  const raw = {
    title: formData.get('title'),
    content: formData.get('content'),
  };
 
  const result = PostSchema.safeParse(raw);
 
  if (!result.success) {
    return {
      errors: result.error.flatten().fieldErrors,
    };
  }
 
  try {
    await db.post.create({ data: result.data });
  } catch {
    return { message: 'Database error. Could not create post.' };
  }
 
  revalidatePath('/posts');
  redirect('/posts'); // throws an internal redirect — happens after try/catch
}

Then the form becomes a Client Component that uses useActionState:

// components/CreatePostForm.tsx
'use client';
 
import { useActionState } from 'react';
import { createPost, type PostActionState } from '@/actions/posts';
 
const initialState: PostActionState = {};
 
export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, initialState);
 
  return (
    <form action={formAction}>
      <div>
        <label htmlFor="title">Title</label>
        <input id="title" name="title" required />
        {state.errors?.title && (
          <p className="text-red-500 text-sm">{state.errors.title[0]}</p>
        )}
      </div>
 
      <div>
        <label htmlFor="content">Content</label>
        <textarea id="content" name="content" required />
        {state.errors?.content && (
          <p className="text-red-500 text-sm">{state.errors.content[0]}</p>
        )}
      </div>
 
      {state.message && (
        <p className="text-red-500">{state.message}</p>
      )}
 
      <button type="submit" disabled={isPending}>
        {isPending ? 'Saving...' : 'Save Post'}
      </button>
    </form>
  );
}

Key things happening here:

  • useActionState wraps createPost and injects prevState as the first argument before formData.
  • The isPending boolean is true while the action is in flight — no manual useState for loading.
  • Field-level errors come back from the server as part of the state object, not as thrown exceptions.
  • If validation fails, the form stays mounted with the errors visible. The user doesn't lose their input.
Rendering diagram...

useFormStatus for Submit Button State

There's a secondary hook worth knowing: useFormStatus. It reads the pending state of the nearest ancestor <form> element. The main use case is when your submit button lives in a separate component:

// components/SubmitButton.tsx
'use client';
 
import { useFormStatus } from 'react-dom';
 
export function SubmitButton({ label }: { label: string }) {
  const { pending } = useFormStatus();
 
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving...' : label}
    </button>
  );
}
// Now the form doesn't need to thread isPending down manually
<form action={formAction}>
  <input name="title" />
  <SubmitButton label="Save Post" />
</form>
⚠️

useFormStatus must be called inside a component that's a child of the form, not in the same component that renders the form. It reads from the nearest ancestor form's context. If you try to use it in the same component as the form, it returns pending: false always.

The rule of thumb: use isPending from useActionState when you need pending state in the same component as the form, and useFormStatus when you want to extract the submit button (or any form-aware UI) into its own component.

Validation: Server-Side Is Not Optional

One thing that trips people up: Server Actions run on the server, so you can't use browser APIs, but you also get to skip the trust issues that come with client-side-only validation.

Never trust FormData from the client. Always validate server-side with Zod or a similar library. The safeParse pattern keeps it clean:

'use server';
 
import { z } from 'zod';
import { getSession } from '@/lib/auth';
 
const UpdateProfileSchema = z.object({
  displayName: z.string().min(1).max(50),
  bio: z.string().max(500).optional(),
});
 
export async function updateProfile(
  prevState: ActionState,
  formData: FormData,
): Promise<ActionState> {
  // Always authenticate. FormData from a fetch() call can hit your action too.
  const session = await getSession();
  if (!session?.user) {
    return { message: 'Unauthorized' };
  }
 
  const result = UpdateProfileSchema.safeParse({
    displayName: formData.get('displayName'),
    bio: formData.get('bio') || undefined,
  });
 
  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }
 
  await db.user.update({
    where: { id: session.user.id },
    data: result.data,
  });
 
  revalidatePath('/profile');
  return { message: 'Profile updated.' };
}
🚨

Server Actions are publicly accessible HTTP endpoints. Anyone can POST to them directly — they're not protected just because you didn't write an API route. Always authenticate and authorize inside the action, not just in middleware or a proxy file.

This is worth seeing rather than taking on faith. Render a Server Component form on Next.js 16.1.6 and the HTML you get back is:

<form action="" encType="multipart/form-data" method="POST">
  <input name="$ACTION_ID_403e464c75e65855b46717ec1ddc1bc619af320ca8" type="hidden" />
  ...
</form>

That action ID is all a caller needs. Copy it out of the page source and POST straight at the route with curl — no cookies, no browser, no JavaScript:

curl -X POST http://localhost:3000/posts/new \
  -F '$ACTION_ID_403e464c75e65855b46717ec1ddc1bc619af320ca8=' \
  -F 'title=NoJSPost' -F 'content=written without javascript'
HTTP/1.1 303 See Other
Location: /posts

The row was created. That 303 is the same mechanism that gives you progressive enhancement — it is genuinely a plain HTML form POST — which is exactly why the action itself has to do the authorization. There is no separate route file to bolt a guard onto.

A related trap: a proxy/middleware matcher is not a substitute here either. Server Functions are handled as POSTs to the route where they're used, so excluding a path from the matcher silently removes coverage from every action on that path, and moving a form to a new route can quietly drop the guard.

Calling Actions Outside Forms

Server Actions aren't limited to form submissions. You can call them from event handlers in Client Components:

'use client';
 
import { deletePost } from '@/actions/posts';
import { useTransition } from 'react';
 
export function DeleteButton({ postId }: { postId: string }) {
  const [isPending, startTransition] = useTransition();
 
  return (
    <button
      onClick={() => startTransition(() => deletePost(postId))}
      disabled={isPending}
    >
      {isPending ? 'Deleting...' : 'Delete'}
    </button>
  );
}

useTransition gives you the pending state without a form. This pattern works for destructive actions, toggles, and anything else that triggers a mutation without a form submission.

The same rule applies with no form in the picture: deletePost needs to check the session and that this user owns that post. postId arrives from the client and a direct POST can carry any value it likes.

Variations Worth Knowing

Keeping input after failed submission. The form loses user input on a re-render because input elements are uncontrolled by default. Pass the previous form data back in the state object if you need to repopulate fields:

export type PostActionState = {
  errors?: { title?: string[]; content?: string[] };
  defaultValues?: { title: string; content: string };
};
 
// In the action, on validation failure:
return {
  errors: result.error.flatten().fieldErrors,
  defaultValues: { title: raw.title as string, content: raw.content as string },
};
 
// In the form:
<input name="title" defaultValue={state.defaultValues?.title} />

Optimistic updates. For low-stakes mutations (toggling a like, reordering items), useOptimistic lets you apply a change immediately and reconcile after the action completes:

'use client';
 
import { useOptimistic, useTransition } from 'react';
import { toggleBookmark } from '@/actions/bookmarks';
 
export function BookmarkButton({ postId, initialBookmarked }: Props) {
  const [isPending, startTransition] = useTransition();
  const [optimisticBookmarked, setOptimistic] = useOptimistic(initialBookmarked);
 
  return (
    <button
      onClick={() =>
        startTransition(async () => {
          setOptimistic(!optimisticBookmarked);
          await toggleBookmark(postId);
        })
      }
    >
      {optimisticBookmarked ? 'Bookmarked' : 'Bookmark'}
    </button>
  );
}

Multiple actions in one form. Use the formAction prop on individual buttons to trigger different actions from the same form:

<form>
  <input name="title" />
  <button formAction={saveDraft}>Save Draft</button>
  <button formAction={publish}>Publish</button>
</form>

Each button submits the same form data but invokes a different server action. Clean way to handle "save" vs "submit" patterns without duplicating the form.

When NOT to Use Server Actions

Server Actions are genuinely good for mutations triggered by user interaction. They're not the right tool everywhere.

Third-party webhooks and integrations. Stripe, GitHub, Slack — these services POST to your endpoint and need an HTTP response. Server Actions aren't designed for this. Keep using API Routes (app/api/...) for inbound webhooks.

REST or GraphQL APIs you control. If a mobile app, CLI, or third-party service consumes your API, those clients can't call Server Actions. You need a proper API route.

Complex file uploads. Server Actions can receive FormData with files, but for large uploads or chunked transfers, you typically want a pre-signed URL flow (generate a URL on the server, upload directly from client to S3) rather than routing the binary through your app server.

High-frequency mutations. Server Actions don't batch. If you're logging keystrokes or auto-saving every few hundred milliseconds, you probably want a debounced fetch to a regular API endpoint, not a Server Action per keystroke.

The heuristic: Server Actions for mutations initiated by user interactions (form submissions, button clicks). API Routes for everything that needs to be callable from outside your Next.js app.

The Mental Model

The thing that makes Server Actions click is understanding what they actually are: they're async functions that React renders a form to POST to, with the function call happening on the server. When JavaScript is available, React intercepts the submit, makes the POST in the background, and updates state from the response. When JavaScript isn't available (first load, slow connection, JS disabled), the form does a plain HTML form submission and the page reloads with the result.

That's progressive enhancement. It wasn't something you had to build — it's what you get by default when you stop intercepting form submissions manually.

The pattern that works for 90% of mutations:

  1. Write an action in actions/ with 'use server' and the (prevState, formData) signature
  2. Authenticate and authorize on the first line — before you even read the FormData, and without relying on a proxy/middleware matcher
  3. Validate with Zod, return structured errors if validation fails
  4. Call revalidatePath or revalidateTag after a successful mutation
  5. Use redirect() if the user should land somewhere new, or return { message: 'Success' } if they should stay
  6. In the Client Component, useActionState gives you state + formAction + isPending. That's all the wiring you need.

The API route for mutations mostly becomes noise at that point. Save it for the endpoints your server actually serves to the outside world.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Parallel routes let a single layout render multiple independent pages at once. Combine them with intercepting routes and you get URL-aware modals with zero hacks.
AdminAugust 3, 20267 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read