Short version for the impatient: I rewrote a side project’s checkout flow last weekend and deleted seven app/api/... route handlers in the process. The form components now call Server Actions directly. The code is shorter, the types are tighter, and I spent about an hour fighting a redirect bug I didn’t see coming. If you want to know why I made the switch (and where I still keep API routes around), read on.
I’m not going to pretend Server Actions are some revolution. They’re a small idea with big consequences for how you organize Next.js apps. After about six months of using them in production, I have opinions on when they pay off, when they don’t, and the specific footguns I keep hitting. This is that writeup.
The mental shift I had to make
The thing that finally clicked for me: a Server Action isn’t an API endpoint that happens to live next to your component. It’s an RPC call dressed up as a function. You write a function on the server, mark it with "use server", and the client gets a generated POST stub that calls it for you.
Here’s what mutating data looked like in my Next.js 13 codebase, before I switched:
// app/api/notes/route.ts
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function POST(req: Request) {
const session = await auth()
if (!session) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
const body = await req.json()
const title = String(body.title ?? '').trim()
if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 })
const note = await db.note.create({
data: { title, userId: session.user.id }
})
return NextResponse.json(note)
}
And the client side:
// app/notes/NewTodo.tsx
'use client'
import { useState } from 'react'
export function NewTodo() {
const [title, setTitle] = useState('')
const [pending, setPending] = useState(false)
async function submit(e: React.FormEvent) {
e.preventDefault()
setPending(true)
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
})
setPending(false)
if (res.ok) setTitle('')
}
return (
<form onSubmit={submit}>
<input value={title} onChange={e => setTitle(e.target.value)} />
<button disabled={pending}>Add</button>
</form>
)
}
Look at that. Two files, a hand-rolled fetch, a hand-rolled pending state, manual JSON serialization on both ends, and zero type safety between them. The client doesn’t know what the server returns; if I rename title to name on the server, the client compiles cleanly and breaks at runtime.
Here’s the same thing with a Server Action:
// app/notes/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createTodo(formData: FormData) {
const session = await auth()
if (!session) throw new Error('unauthorized')
const title = String(formData.get('title') ?? '').trim()
if (!title) throw new Error('title required')
await db.note.create({
data: { title, userId: session.user.id }
})
revalidatePath('/notes')
}
// app/notes/NewTodo.tsx
import { createTodo } from './actions'
export function NewTodo() {
return (
<form action={createTodo}>
<input name="title" />
<button>Add</button>
</form>
)
}
No client component. No useState. No fetch. The form’s action attribute now accepts a server function directly, and Next.js handles the wire protocol. If I rename the export, TypeScript yells at me on both sides because the import is a real function reference. That’s the whole pitch in one diff.
What actually replaced API routes for me
I didn’t go nuclear and delete every route handler. I made a rule for myself: a Server Action is right when the call is internal, mutating, and co-located with the component that calls it. An API route is right when any of those three break.
Internal: the only consumer is my own UI. Server Actions are not a public API. Their endpoints are POST-only, the URLs include an action ID, and they’re meant to be called by the framework’s generated stubs. If a mobile app or a Zapier webhook needs to hit the same logic, that’s an API route (or a tRPC procedure, which I covered in my tRPC writeup).
Mutating: writes, not reads. Reads should happen in Server Components during render, not via a POST round-trip. I see people use Server Actions for getUser() and it makes me twitch. They’re called via POST, they bypass the React cache, and you lose streaming. Just await the query in the Server Component.
Co-located: the component that triggers the action is the only one that calls it. The moment two pages need the same mutation logic with different UX, I extract the underlying function and let both call it. The Action stays a thin shell.
The Next.js docs are direct about this. The official Server Actions guide calls them “asynchronous functions that are executed on the server” and explicitly frames them as a tool for mutations and form submissions. They are not a replacement for your whole backend.
The progressive enhancement bit that surprised me
I’d read about progressive enhancement with Server Actions before I tried them. I assumed it was a checkbox feature nobody actually relied on. Then I disabled JavaScript in DevTools, hit submit, and the form still worked. Not “degraded gracefully”. Actually worked. Submitted the form, ran the action, redirected back, showed the new note.
That’s because passing a server function to form action produces real HTML that posts to a real URL. The client JS hijacks it when it’s available and turns it into a fetch, but the underlying form is a form. This is the part of React 19’s design that I think is undersold. The React Actions documentation treats this as the default, not an edge case.
For a content site or an admin dashboard where users might have flaky connections, this is genuinely useful. For a SPA-feel product, it’s a nice-to-have. But I stopped writing client components for forms unless I have a real reason — most of mine don’t need state at all.
Pending UI without writing pending UI
The pending state I used to track with useState is now useFormStatus. You drop it inside the form and it tells you whether the action is in flight:
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending}>
{pending ? 'Adding\u2026' : 'Add'}
</button>
)
}
The gotcha: useFormStatus reads from the nearest parent <form>. It only works in a child component, not in the form itself. That tripped me up the first time. I had the hook in the same component that rendered the <form>, and pending was always false. Five minutes of staring before I read the docs properly. The hook is reading the wrong form’s context, because it’s the form’s parent.
For optimistic UI I reach for useOptimistic, which I wrote about in detail in my post on optimistic UI I stopped hand-rolling. The pair is meant to be used together: useFormStatus for the spinner, useOptimistic for the optimistic update, and the Server Action for the actual write.
The error handling story is rougher than I’d like
This is the part where I admit I’m not fully sold yet. Throwing in a Server Action gives you a stack trace on the server and a generic error on the client. There’s no built-in field-level validation pattern. You can return an error object instead of throwing, but then every action becomes a tagged union and every form has to discriminate it.
The pattern I’ve settled on uses useActionState:
// actions.ts
'use server'
export type CreateTodoState = { error?: string } | { ok: true }
export async function createTodo(
_prev: CreateTodoState,
formData: FormData
): Promise<CreateTodoState> {
const title = String(formData.get('title') ?? '').trim()
if (!title) return { error: 'Title is required' }
if (title.length > 200) return { error: 'Title too long' }
try {
await db.note.create({ data: { title, userId: '...' } })
} catch (e) {
return { error: 'Could not save. Try again?' }
}
return { ok: true }
}
// NewTodo.tsx
'use client'
import { useActionState } from 'react'
import { createTodo } from './actions'
export function NewTodo() {
const [state, formAction] = useActionState(createTodo, { ok: true } as any)
return (
<form action={formAction}>
<input name="title" />
<button>Add</button>
{'error' in state && state.error && <p role="alert">{state.error}</p>}
</form>
)
}
It works. It’s also more ceremony than I’d want for every form. I’ve started reaching for a small wrapper that takes a Zod schema and returns a validated action, which gets me back to the brevity I had before. If you’re already using Zod with the validation patterns I covered for runtime types, this is the natural extension.
For unexpected errors (database down, third-party API timing out), I let them throw and catch them at the route’s error.tsx boundary. That’s worked fine. The split is: expected user-input errors return state, infrastructure failures throw.
Security: what you actually have to think about
Next.js encrypts the action ID and includes a CSRF check when the request origin doesn’t match the host. That covers the basic case where a third-party site tries to POST to your action. What it does not cover: authorization.
Every Server Action runs your code with whatever permissions the request has. There’s no automatic “is this user allowed to do this” gate. If you ship a deleteTodo(id) action without checking session.user.id === note.userId, anybody with an authenticated session can delete anybody’s notes by sending the right form data. I caught this in code review on a project last quarter — the original author assumed the action being “server-only” meant it was somehow protected. It is not. It’s a POST endpoint that anyone with a logged-in session can hit.
My rule: every Server Action starts with an auth check and an authorization check. No exceptions. If the action mutates a resource owned by a user, it verifies ownership before doing anything else. The Next.js security docs for Server Actions call this out specifically, and it’s the first thing I review when somebody adds a new action to one of my projects.
Where I still keep API routes
Four cases, in order of how often they come up for me:
Webhooks. Stripe, Resend, Clerk, whatever. They’re external callers, they need a stable URL, they need to verify a signature against a raw body, and they don’t fit the Server Action pattern at all. These stay as route handlers.
Public JSON APIs for a mobile client or partner integration. Same logic — Server Actions aren’t a public surface. If somebody outside my web app needs to call this, I write a real route handler with documented request/response shapes.
Long-running streaming responses. SSE for a chat UI, for example. Server Actions are POST request/response; they don’t stream a body back. Route handlers with ReadableStream do.
File downloads with custom headers. Generating a CSV with Content-Disposition: attachment, signing a PDF, that kind of thing. The action wire protocol is not built for this.
Everything else — the form submissions, the toggle buttons, the “add to cart” clicks, the comment posts — I write as Server Actions now. If you want a sense of the kind of thing I build with this setup, I keep a few of these patterns documented in my work.
One concrete thing to try this week
Pick one form in your Next.js app that currently posts to an API route. Probably a login form or a comment form. Rewrite it as a Server Action and a Server Component. Notice how much code you delete. Notice the things that get harder (you’ll probably want useActionState for the error display).
If the experience is good, you’ll find yourself doing it to the next form, and the one after that. If it isn’t, at least you have a concrete data point about what your codebase actually needs, instead of arguing about it on Twitter. I went through this exact exercise on a project last spring and ended up keeping about 30% of my route handlers; the rest became Actions. Your number will be different. The point is to find out by doing it once, not by debating it.
The API routes I kept are the ones I should have kept. The forms I migrated were the ones I should have migrated. Six months later, I haven’t reversed any of those decisions, and that’s the closest thing to a recommendation I’m willing to make.