Confession: I rewrote a perfectly good API route into a server action last summer, broke a Stripe webhook in the process, and didn’t notice for two days. The webhook signature check kept failing because I’d thrown out the raw body somewhere in the conversion. I had to roll the whole thing back, drink a coffee, and write a list of “things I will not do with server actions again.”
This post is that list. Six mistakes, all real, all stuff I’ve shipped to production at some point and had to clean up. If you’ve already read why I stopped writing API routes for most things, this is the other half of that conversation. Server actions are great. They’re also a foot-gun if you treat them like a free abstraction.
Quick context: everything below is for Next.js 15 with the App Router. If you’re on Pages Router, most of this doesn’t apply, and you have my sympathy.
1. Reaching for a server action when a route handler was the right call
The first time you write a server action, the temptation is to use them for everything. Form submission? Action. Data fetching? Action. Receiving a webhook from Stripe, Clerk, or GitHub? Surely an action.
That last one is wrong. Server actions are not designed to be hit from outside your app. They’re invoked by your own React components via a generated POST endpoint with framework-specific encoding. Stripe doesn’t know about that encoding. Stripe wants a raw JSON body it can verify with stripe.webhooks.constructEvent.
Here’s the rule I now follow: if the caller isn’t a React component in this codebase, write a route handler. If the caller is one of my components, write a server action.
// Wrong: trying to be clever with a server action
'use server'
export async function handleStripeWebhook(req: Request) {
// Stripe has no idea what this signature looks like
}
// Right: route handler, raw body preserved
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')!
const body = await req.text() // raw, not parsed
const event = stripe.webhooks.constructEvent(body, sig, secret)
// ...
}
The Next.js docs on server actions say it plainly: actions are for the client-server roundtrip inside your app. External callers go through app/api/.
2. Trusting the client to validate the form
Server actions look so much like a regular function call that it’s easy to forget the body crossed the network. I had a project where I validated the form with react-hook-form plus zod on the client, then passed the parsed object straight into the action. The action wrote it to the database with no further checks.
If you do this, anyone who can hit your site can post arbitrary JSON to your action endpoint with curl. The Zod schema in the client component runs in their browser, not yours. They can simply not run it.
The fix is dull but necessary: validate on the server, every time.
'use server'
import { z } from 'zod'
const schema = z.object({
email: z.string().email(),
plan: z.enum(['free', 'pro']),
})
export async function subscribe(formData: FormData) {
const parsed = schema.safeParse({
email: formData.get('email'),
plan: formData.get('plan'),
})
if (!parsed.success) {
return { error: 'Invalid input' }
}
// now you can trust parsed.data
}
I keep the same Zod schema in a shared file and import it on both sides. Client validates for UX. Server validates for correctness.
3. Forgetting revalidatePath and blaming React
You’d think mutating data on the server would, you know, update the UI. It doesn’t, not by itself. The router caches the rendered output of server components, and your action has to tell the router to throw that cache away.
I lost an evening on this with a comments form. The action wrote the comment, the database had it, but refreshing didn’t show the new row. I assumed React was being weird. React was being React. The router cache was stale.
'use server'
import { revalidatePath } from 'next/cache'
export async function addComment(postId: string, body: string) {
await db.comment.create({ data: { postId, body } })
revalidatePath(`/posts/${postId}`) // tell the router to re-fetch
}
If you tag your fetches, revalidateTag does the same thing more surgically. Either way, no revalidation call means stale UI. The Next.js caching docs are worth a slow read at least once.
4. Throwing an Error and getting a wall of stack trace in the toast
Server actions can throw, but the error that reaches the client is sanitized by default. In production it gets a generic message and a digest. Your nice throw new Error('You are not subscribed') becomes “An error occurred in the Server Components render.”
I shipped that to a customer. They saw the generic message in a toast and assumed the app was broken.
The fix is to return errors as values, not throw them:
'use server'
export async function startTrial(userId: string) {
const user = await db.user.findUnique({ where: { id: userId } })
if (!user) {
return { ok: false, error: 'User not found' }
}
if (user.trialUsed) {
return { ok: false, error: 'Trial already used' }
}
await db.user.update({ where: { id: userId }, data: { trialUsed: true } })
return { ok: true }
}
Reserve thrown errors for genuinely unexpected stuff (DB unreachable, third-party down). For business logic, return a tagged union and pattern match on it in the component. Your toasts will thank you. The React docs on actions cover the pairing with useActionState if you want the cleanest pattern.
5. Using actions for high-frequency events
Server actions are POST requests under the hood, which means they’re not great for things that fire many times a second. I tried to use one to log pointer movement for a heatmap feature. It worked, in the sense that it ran. It also made the network tab look like a slot machine and racked up a chunky Vercel bill.
Anything that needs to be cheap and frequent belongs somewhere else: a websocket, a batched POST to a route handler, or navigator.sendBeacon on unload. Server actions are for user-initiated mutations, not telemetry.
The heuristic I use: if the user wouldn’t notice a 200ms latency on each call, you’re probably using actions for the wrong thing.
6. Not reading what gets shipped to the client
Server actions get serialized into the client bundle as references that point at server-side code. The action body never ships, but its arguments and return values do, and so does any module-level code in the file that imports the action.
Once I imported a server action into a client component from a file that also exported a heavy data-loading helper. The helper itself didn’t run on the client, but its import side effects pulled in roughly 80KB of pricing tables that I really did not need users to download.
Two habits help. First, put server actions in their own file (app/actions/whatever.ts), not next to other server-only code. Second, run next build and look at the route summary. The first-load JS column will tell you if something has crept in.
If it’s bigger than you expect, run ANALYZE=true next build with @next/bundle-analyzer and find out what.
What I’d actually do on a fresh project today
Use server actions for forms and mutations. Use route handlers for webhooks and any external caller. Validate on the server. Pair mutations with revalidatePath or revalidateTag. Return errors as values. Watch the bundle.
That’s it. The mental model is small once you stop trying to make actions do everything. I cover this kind of pragmatic Next.js work in my portfolio of recent projects if you want a longer look at how I structure App Router apps in production.
If you want one concrete thing to do this week: open one of your existing server actions, count how many of these six mistakes are in it, and fix the cheapest one. Mine usually have two. Yours probably has fewer than you fear.