May 5, 2026 · 10 min read · Cadence Editorial

How to use Server Actions in Next.js 15

server actions nextjs 15 — How to use Server Actions in Next.js 15
Photo by [Bibek ghosh](https://www.pexels.com/@bibekghosh) on [Pexels](https://www.pexels.com/photo/code-on-computer-screen-14553730/)

How to use Server Actions in Next.js 15

Server Actions in Next.js 15 are async functions you mark with 'use server' and call directly from React components, and they replace most of the boilerplate you used to write for /api routes when handling form submissions and mutations. The framework compiles them into POST endpoints, wires up CSRF protection, and dead-code-eliminates any action you don't actually call from the client bundle.

That is the happy-path pitch. The reality of shipping them to a paying user base is messier, and most tutorials skip the messy part. This post is the production playbook: the six-step pattern that survives a security review, the four gotchas that take down most first-time rollouts, and a clear answer to "when should I just use an API route instead."

Why Server Actions matter in 2026

Two things changed in the last 18 months. First, Next.js 15 stabilized the security model for actions: each action gets an encrypted ID per build, unused actions are stripped from the client bundle, and Next now refuses to invoke an action whose ID it can't decrypt. Second, React 19 shipped useActionState and a working useFormStatus, which means progressive-enhancement forms (forms that submit even with JavaScript disabled) are finally a one-liner instead of a research project.

The combination matters because the alternative, hand-rolled API routes plus client-side fetch plus optimistic state, is roughly 4x the code for the same UX. For small teams shipping a CRUD-heavy product, the LOC savings compound fast.

The default approach (and where it breaks)

Most teams reach for the textbook example:

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

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  await db.posts.create({ data: { title } });
  revalidatePath('/posts');
}

This works in a demo. It breaks in production for four predictable reasons:

  1. No authentication check. The action ID is exposed to the browser. Anyone who hits your site can call createPost with arbitrary input.
  2. No input validation. formData.get('title') returns string | File | null. The cast lies. A malicious client can send a File, an empty string, or a 50MB string.
  3. No error path. If db.posts.create throws, the user sees an unstyled error overlay in dev and a blank screen in prod.
  4. No rate limiting. Server Actions are POST endpoints. They are as DDoS-able as any other endpoint, and the encrypted action ID does not change that.

Every one of these is a real incident I've seen ship. Let's fix them in order.

The six-step playbook

Step 1: Co-locate actions in actions.ts files, not in pages

app/
  posts/
    page.tsx
    actions.ts   <-- all 'use server' functions for /posts

This makes the security perimeter visible. Anyone reviewing the repo can grep for 'use server' files and audit every public mutation in one pass. Inline actions inside Server Components work, but they scatter the perimeter across 40 files and make audits hell.

Step 2: Validate every input with Zod (or Valibot)

Treat the function signature as a lie. The client sends bytes; you decode them.

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

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

const CreatePostSchema = z.object({
  title: z.string().trim().min(1).max(200),
  body: z.string().trim().min(1).max(10_000),
});

export async function createPost(_prev: unknown, formData: FormData) {
  const parsed = CreatePostSchema.safeParse({
    title: formData.get('title'),
    body: formData.get('body'),
  });

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

  // ... continue
}

Two things to notice. First, the action returns a typed result instead of throwing. That is the contract useActionState expects. Second, the _prev argument is the previous state, which React passes automatically. Forgetting it is the most common rookie bug; the form will appear to work, then silently lose state on the second submit.

Step 3: Wrap the action in an auth-and-context middleware

Next.js does not run middleware on server actions the way it runs middleware on pages. You build your own. The cleanest pattern is a small higher-order function:

// lib/safe-action.ts
import { auth } from '@/auth'; // your NextAuth / Clerk / Lucia setup

type Ctx = { userId: string; orgId: string };

export function withAuth<TIn, TOut>(
  fn: (ctx: Ctx, input: TIn) => Promise<TOut>
) {
  return async (_prev: unknown, input: TIn) => {
    const session = await auth();
    if (!session?.user) {
      return { ok: false, errors: { _form: ['Not authenticated'] } };
    }
    return fn(
      { userId: session.user.id, orgId: session.user.orgId },
      input
    );
  };
}

If you'd rather not roll your own, next-safe-action does this with chainable middleware and full type inference. Either path is fine; the rule is "no action runs without an explicit auth check, full stop."

Step 4: Add rate limiting at the action boundary

Server Actions hit the same edge runtime as your pages. They need the same throttling. Upstash Ratelimit is the lowest-friction option:

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const limiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '1 m'),
});

export async function createPost(_prev: unknown, formData: FormData) {
  const session = await auth();
  const key = session?.user?.id ?? headers().get('x-forwarded-for') ?? 'anon';
  const { success } = await limiter.limit(`create-post:${key}`);
  if (!success) {
    return { ok: false, errors: { _form: ['Too many requests'] } };
  }
  // ... continue
}

Rate by user ID when authenticated, IP when not. Keep the buckets per-action; a global limiter punishes legitimate users when one endpoint gets hammered. This pattern pairs well with the principles in our API design playbook, since server actions are just opinionated API endpoints under the hood.

Step 5: Wire the form with useActionState for progressive enhancement

The form should work without JavaScript, then upgrade once React hydrates.

// app/posts/new-post-form.tsx
'use client';

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createPost } from './actions';

const initialState = { ok: false, errors: {} as Record<string, string[]> };

export function NewPostForm() {
  const [state, formAction] = useActionState(createPost, initialState);

  return (
    <form action={formAction}>
      <input name="title" required />
      {state.errors?.title && <p>{state.errors.title[0]}</p>}
      <textarea name="body" required />
      {state.errors?.body && <p>{state.errors.body[0]}</p>}
      <SubmitButton />
      {state.errors?._form && <p role="alert">{state.errors._form[0]}</p>}
    </form>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}

The key detail: <form action={formAction}> is a real HTML form. If the user has JavaScript disabled, the browser still POSTs to the action's URL and Next.js still runs the function. Progressive enhancement is not optional in 2026, especially for forms behind flaky mobile networks or restrictive corporate proxies.

Step 6: Revalidate, don't refetch

After a successful mutation, tell Next which cached paths or tags are stale.

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

await db.posts.create({ data: parsed.data });
revalidateTag('posts');           // every page that fetched with tags: ['posts']
revalidatePath('/posts');         // a specific route

Use revalidateTag when multiple pages depend on the same data. Use revalidatePath when a single route is the source of truth. Avoid calling both for the same data; you double-invalidate and double the regeneration cost.

Server Actions vs API Routes: a short decision matrix

Use caseReach forWhy
Form submit, same-app, mutationServer ActionLess code, automatic CSRF, progressive enhancement
Public REST endpoint for partnersAPI RouteYou need verbs, status codes, and OpenAPI docs
Webhook receiver (Stripe, GitHub)API RouteSignature verification needs raw body, not FormData
Mobile app talking to your backendAPI RouteNative clients can't invoke encrypted action IDs
Read-only data fetch in a Client ComponentNeither, use a Server ComponentServer Actions are POST-only on purpose
Admin dashboard mutationsServer ActionAuth and form UX are the entire story

The dividing line is "is the caller a React component in this same app?" If yes, Server Action. If anything else (curl, Postman, a partner, a mobile app), API Route. Your team will save weeks if you put this rule on the wall before someone tries to call a server action from a React Native app.

Common pitfalls

A handful of patterns look right and break in production:

  • Returning data from an action and then redirect()-ing. redirect throws internally. If you put it after a return, the return wins. If you put it before, the data is lost. Pick one: redirect on success, return on error.
  • Trusting cookies() set inside an action. Cookies set during a Server Action only apply to the response of that action. The next page render does not see them unless you also revalidatePath. Burn this into your memory; the symptom is "user appears logged out for one page load."
  • Catching errors and returning generic messages without logging. Wrap every action in try/catch, log the real error to Sentry or Axiom, then return { ok: false, errors: { _form: ['Something went wrong'] } } to the client. Silent failures in actions are catastrophic; the user sees nothing and the dev sees nothing.
  • Sharing one Zod schema between the action and the client. Tempting, dangerous. The client schema validates UX hints (max length for the input). The server schema validates security boundaries. Keep them separate, even if they overlap 95%.
  • Putting heavy work inline. Actions that take more than ~3 seconds will hit serverless function timeouts. Push to a queue (Inngest, Trigger.dev, Upstash QStash) and return immediately.

When you can skip Server Actions entirely

Be honest about scope. If you are a 2-founder team pre-revenue with three forms in your whole app, the difference between Server Actions and a plain /api/post route is 30 minutes of your life. Pick whatever your senior dev has done before.

You should also skip Server Actions when:

  • Your frontend is not Next.js (Astro, Remix, Vite + React all have their own primitives).
  • Your backend is in a different language (Go, Python, Rust). You are paying the cognitive overhead of a JS edge runtime for nothing.
  • Your mutations are mostly fire-and-forget background jobs. Use a queue directly; don't dress up a queue push as a form action.

A working setup checklist

If you are rolling Server Actions out across an existing app, work in this order:

  1. Create lib/safe-action.ts with your auth wrapper.
  2. Add @upstash/ratelimit and a Redis instance.
  3. Pick one feature (settings page is a good first target). Migrate its forms.
  4. Add Sentry or Axiom for action-level error reporting.
  5. Write one Playwright test per action that asserts both happy path and validation failure.
  6. Roll out across the rest of the app one route at a time.

Most teams finish step 1-4 in a single week. Steps 5-6 take as long as your test culture demands.

The Cadence connection

Server Actions are a short, opinionated rollout, exactly the kind of work a senior Cadence engineer ($1,500/week) closes inside a 48-hour trial. Every engineer on Cadence is AI-native by default, vetted on Cursor and Claude Code fluency before they unlock bookings, so they ship the migration with the safety wrapper, rate limits, and Playwright tests in place rather than the bare-minimum tutorial version. If you'd rather size the work first, audit your stack with our ship-or-skip tool and get an honest grade on whether a Server Actions migration is your highest-ROI move this quarter.

The other path is a mid-tier engineer ($1,000/week) for the rote migration once a senior has authored the wrapper. We see this pattern in the Cadence pool of 12,800 engineers most often: one senior week to set the architecture, two mid weeks to convert the rest of the routes. It is the cheapest way to upgrade a Next.js codebase without burning your in-house team's roadmap.

If your team is staring at a Server Actions migration and wondering whether it's worth a quarter, run the audit before you book anyone. You'll get a graded recommendation in under five minutes, and a clear scope if it turns out to be worth it. If you'd rather skip the audit and start, book a senior engineer on a 48-hour trial; you only pay for the week if you keep them.

If you are also navigating bigger architecture calls in the same sprint, our notes on SQL vs NoSQL in 2026 and on how our matching algorithm scores 12,800 engineers in 80ms cover adjacent ground worth a skim.

FAQ

Do Server Actions replace API routes entirely?

No. Server Actions handle form submissions and component-driven mutations inside your own Next.js app. API Routes are still the right call for public REST endpoints, webhooks, mobile-app backends, and anything that needs HTTP verbs other than POST.

Are Server Actions secure by default?

Partially. Next.js 15 encrypts action IDs and strips unused actions from the client bundle. It does not authenticate users, validate input, or rate-limit. You must add all three at the action boundary; the framework does not do it for you.

Can Server Actions work without JavaScript?

Yes. A <form action={serverAction}> element will submit via standard HTML form POST when JavaScript is disabled or fails to hydrate. Use useActionState for the enhanced UX; the underlying form works either way.

How do I test Server Actions?

Two layers. Unit-test the action function directly by calling it with a constructed FormData object. End-to-end test the form using Playwright, which exercises the full POST round trip including the encrypted action ID flow. Mock your auth and database at the unit layer; let them run for real at the E2E layer.

What happens when the action ID encryption key rotates?

Old action IDs become invalid. Users with stale tabs open will see a "server error" on next submit. Mitigate by invalidating sessions on deploy, or by running a graceful key rotation window where both old and new keys validate (Next.js exposes this via the serverActions.encryptionKey config and a previous-key fallback).

All posts