{"id":161,"date":"2026-04-28T05:03:40","date_gmt":"2026-04-28T05:03:40","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-mistakes-i-made\/"},"modified":"2026-04-28T05:03:40","modified_gmt":"2026-04-28T05:03:40","slug":"nextjs-server-actions-mistakes-i-made","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-mistakes-i-made\/","title":{"rendered":"Next.js Server Actions: 6 Mistakes I Made So You Don&#8217;t Have To"},"content":{"rendered":"<p>Confession: I rewrote a perfectly good API route into a server action last summer, broke a Stripe webhook in the process, and didn&rsquo;t notice for two days. The webhook signature check kept failing because I&rsquo;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 &ldquo;things I will not do with server actions again.&rdquo;<\/p>\n<p>This post is that list. Six mistakes, all real, all stuff I&rsquo;ve shipped to production at some point and had to clean up. If you&rsquo;ve already read <a href=\"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-stopped-writing-api-routes\/\" rel=\"noopener\">why I stopped writing API routes for most things<\/a>, this is the other half of that conversation. Server actions are great. They&rsquo;re also a foot-gun if you treat them like a free abstraction.<\/p>\n<p>Quick context: everything below is for Next.js 15 with the App Router. If you&rsquo;re on Pages Router, most of this doesn&rsquo;t apply, and you have my sympathy.<\/p>\n<h2 id=\"1-reaching-for-a-server-action-when-a-route-handler-was-the-right-call\">1. Reaching for a server action when a route handler was the right call<\/h2>\n<p>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.<\/p>\n<p>That last one is wrong. Server actions are not designed to be hit from outside your app. They&rsquo;re invoked by your own React components via a generated POST endpoint with framework-specific encoding. Stripe doesn&rsquo;t know about that encoding. Stripe wants a raw JSON body it can verify with <code>stripe.webhooks.constructEvent<\/code>.<\/p>\n<p>Here&rsquo;s the rule I now follow: if the caller isn&rsquo;t a React component in this codebase, write a route handler. If the caller is one of my components, write a server action.<\/p>\n<pre><code class=\"language-ts\">\/\/ Wrong: trying to be clever with a server action\n'use server'\nexport async function handleStripeWebhook(req: Request) {\n  \/\/ Stripe has no idea what this signature looks like\n}\n\n\/\/ Right: route handler, raw body preserved\n\/\/ app\/api\/webhooks\/stripe\/route.ts\nexport async function POST(req: Request) {\n  const sig = req.headers.get('stripe-signature')!\n  const body = await req.text() \/\/ raw, not parsed\n  const event = stripe.webhooks.constructEvent(body, sig, secret)\n  \/\/ ...\n}\n<\/code><\/pre>\n<p>The <a href=\"https:\/\/nextjs.org\/docs\/app\/building-your-application\/data-fetching\/server-actions-and-mutations\" rel=\"nofollow noopener\" target=\"_blank\">Next.js docs on server actions<\/a> say it plainly: actions are for the client-server roundtrip inside your app. External callers go through <code>app\/api\/<\/code>.<\/p>\n<h2 id=\"2-trusting-the-client-to-validate-the-form\">2. Trusting the client to validate the form<\/h2>\n<p>Server actions look so much like a regular function call that it&rsquo;s easy to forget the body crossed the network. I had a project where I validated the form with <code>react-hook-form<\/code> 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.<\/p>\n<p>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.<\/p>\n<p>The fix is dull but necessary: validate on the server, every time.<\/p>\n<pre><code class=\"language-ts\">'use server'\nimport { z } from 'zod'\n\nconst schema = z.object({\n  email: z.string().email(),\n  plan: z.enum(['free', 'pro']),\n})\n\nexport async function subscribe(formData: FormData) {\n  const parsed = schema.safeParse({\n    email: formData.get('email'),\n    plan: formData.get('plan'),\n  })\n  if (!parsed.success) {\n    return { error: 'Invalid input' }\n  }\n  \/\/ now you can trust parsed.data\n}\n<\/code><\/pre>\n<p>I keep the same Zod schema in a shared file and import it on both sides. Client validates for UX. Server validates for correctness.<\/p>\n<h2 id=\"3-forgetting-revalidatepath-and-blaming-react\">3. Forgetting revalidatePath and blaming React<\/h2>\n<p>You&rsquo;d think mutating data on the server would, you know, update the UI. It doesn&rsquo;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.<\/p>\n<p>I lost an evening on this with a comments form. The action wrote the comment, the database had it, but refreshing didn&rsquo;t show the new row. I assumed React was being weird. React was being React. The router cache was stale.<\/p>\n<pre><code class=\"language-ts\">'use server'\nimport { revalidatePath } from 'next\/cache'\n\nexport async function addComment(postId: string, body: string) {\n  await db.comment.create({ data: { postId, body } })\n  revalidatePath(`\/posts\/${postId}`) \/\/ tell the router to re-fetch\n}\n<\/code><\/pre>\n<p>If you tag your fetches, <code>revalidateTag<\/code> does the same thing more surgically. Either way, no revalidation call means stale UI. The <a href=\"https:\/\/nextjs.org\/docs\/app\/building-your-application\/caching\" rel=\"nofollow noopener\" target=\"_blank\">Next.js caching docs<\/a> are worth a slow read at least once.<\/p>\n<h2 id=\"4-throwing-an-error-and-getting-a-wall-of-stack-trace-in-the-toast\">4. Throwing an Error and getting a wall of stack trace in the toast<\/h2>\n<p>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 <code>throw new Error('You are not subscribed')<\/code> becomes &ldquo;An error occurred in the Server Components render.&rdquo;<\/p>\n<p>I shipped that to a customer. They saw the generic message in a toast and assumed the app was broken.<\/p>\n<p>The fix is to return errors as values, not throw them:<\/p>\n<pre><code class=\"language-ts\">'use server'\nexport async function startTrial(userId: string) {\n  const user = await db.user.findUnique({ where: { id: userId } })\n  if (!user) {\n    return { ok: false, error: 'User not found' }\n  }\n  if (user.trialUsed) {\n    return { ok: false, error: 'Trial already used' }\n  }\n  await db.user.update({ where: { id: userId }, data: { trialUsed: true } })\n  return { ok: true }\n}\n<\/code><\/pre>\n<p>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 <a href=\"https:\/\/react.dev\/reference\/rsc\/server-functions\" rel=\"nofollow noopener\" target=\"_blank\">React docs on actions<\/a> cover the pairing with <code>useActionState<\/code> if you want the cleanest pattern.<\/p>\n<h2 id=\"5-using-actions-for-high-frequency-events\">5. Using actions for high-frequency events<\/h2>\n<p>Server actions are POST requests under the hood, which means they&rsquo;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.<\/p>\n<p>Anything that needs to be cheap and frequent belongs somewhere else: a websocket, a batched POST to a route handler, or <code>navigator.sendBeacon<\/code> on unload. Server actions are for user-initiated mutations, not telemetry.<\/p>\n<p>The heuristic I use: if the user wouldn&rsquo;t notice a 200ms latency on each call, you&rsquo;re probably using actions for the wrong thing.<\/p>\n<h2 id=\"6-not-reading-what-gets-shipped-to-the-client\">6. Not reading what gets shipped to the client<\/h2>\n<p>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.<\/p>\n<p>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&rsquo;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.<\/p>\n<p>Two habits help. First, put server actions in their own file (<code>app\/actions\/whatever.ts<\/code>), not next to other server-only code. Second, run <code>next build<\/code> and look at the route summary. The first-load JS column will tell you if something has crept in.<\/p>\n<p>If it&rsquo;s bigger than you expect, run <code>ANALYZE=true next build<\/code> with <code>@next\/bundle-analyzer<\/code> and find out what.<\/p>\n<h2 id=\"what-id-actually-do-on-a-fresh-project-today\">What I&rsquo;d actually do on a fresh project today<\/h2>\n<p>Use server actions for forms and mutations. Use route handlers for webhooks and any external caller. Validate on the server. Pair mutations with <code>revalidatePath<\/code> or <code>revalidateTag<\/code>. Return errors as values. Watch the bundle.<\/p>\n<p>That&rsquo;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 <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">portfolio of recent projects<\/a> if you want a longer look at how I structure App Router apps in production.<\/p>\n<p>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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Six Next.js server actions mistakes I shipped to production: trusting the client, missing revalidatePath, throwing errors wrong. With code fixes for each.<\/p>\n","protected":false},"author":2,"featured_media":160,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Six Next.js server actions mistakes I shipped to production: trusting the client, missing revalidatePath, throwing errors wrong. With code fixes for each.","rank_math_focus_keyword":"nextjs server actions","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[35],"tags":[171,61,41,170,172],"class_list":["post-161","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-web-development","tag-app-router-2","tag-nextjs","tag-react","tag-server-actions-2","tag-web-development-2"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/161","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=161"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/161\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/160"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=161"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=161"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=161"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}