{"id":87,"date":"2026-04-16T09:24:42","date_gmt":"2026-04-16T09:24:42","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/?p=87"},"modified":"2026-04-16T09:24:42","modified_gmt":"2026-04-16T09:24:42","slug":"nextjs-server-actions-stopped-writing-api-routes","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-stopped-writing-api-routes\/","title":{"rendered":"Next.js Server Actions Finally Pushed Me Off API Routes"},"content":{"rendered":"<p>Confession: I kept writing <code>\/api\/contact.ts<\/code> routes in Next.js long after server actions went stable. Maybe a year past when I should have stopped. I&rsquo;d read the RFC, nod along, say &ldquo;yep, makes sense,&rdquo; then open a new route handler out of muscle memory the next time I needed a form to submit.<\/p>\n<p>Last month I rebuilt a small client dashboard from scratch and made myself finish the whole thing without a single API route for a form. I&rsquo;m not going back. The old pattern feels like writing two halves of the same function and hoping the types agree somewhere in the middle.<\/p>\n<p>Here&rsquo;s what changed, the spots where server actions tripped me, and the cases where I still reach for a plain route handler.<\/p>\n<h2 id=\"the-old-way-and-why-i-kept-doing-it\">The old way, and why I kept doing it<\/h2>\n<p>A typical form submit in pre-actions Next.js looked like this. You wrote the route handler:<\/p>\n<pre><code class=\"language-ts\">\/\/ app\/api\/contact\/route.ts\nimport { NextResponse } from 'next\/server'\nimport { z } from 'zod'\nimport { saveContact } from '@\/lib\/db'\n\nconst schema = z.object({\n  name: z.string().min(1),\n  email: z.string().email(),\n  message: z.string().min(10),\n})\n\nexport async function POST(req: Request) {\n  const body = await req.json()\n  const parsed = schema.safeParse(body)\n  if (!parsed.success) {\n    return NextResponse.json(\n      { error: parsed.error.flatten() },\n      { status: 400 },\n    )\n  }\n  await saveContact(parsed.data)\n  return NextResponse.json({ ok: true })\n}\n<\/code><\/pre>\n<p>Then the client form:<\/p>\n<pre><code class=\"language-tsx\">'use client'\nimport { useState } from 'react'\n\nexport function ContactForm() {\n  const [pending, setPending] = useState(false)\n  const [error, setError] = useState&lt;string | null&gt;(null)\n\n  async function onSubmit(e: React.FormEvent&lt;HTMLFormElement&gt;) {\n    e.preventDefault()\n    setPending(true)\n    setError(null)\n    const data = Object.fromEntries(new FormData(e.currentTarget))\n    const res = await fetch('\/api\/contact', {\n      method: 'POST',\n      body: JSON.stringify(data),\n      headers: { 'Content-Type': 'application\/json' },\n    })\n    setPending(false)\n    if (!res.ok) setError('Something went wrong')\n  }\n\n  \/\/ ...form JSX\n}\n<\/code><\/pre>\n<p>This works. I shipped it for years. But look at what&rsquo;s actually happening. The Zod schema lives on one side. The client has to guess the response shape. FormData gets serialised to JSON, parsed, validated, then serialised again on the way back. Error handling is manual. Pending state is manual. If I forget to <code>await<\/code> on the fetch I won&rsquo;t find out until the next release cycle, because the types won&rsquo;t complain.<\/p>\n<p>It&rsquo;s two functions pretending to be one, talking through a string.<\/p>\n<h2 id=\"what-a-server-action-actually-is\">What a server action actually is<\/h2>\n<p><img decoding=\"async\" alt=\"Next.js Server Actions Finally Pushed Me Off API Routes\" src=\"https:\/\/abrarqasim.com\/blog\/wp-content\/uploads\/2026\/04\/nextjs-server-actions-stopped-writing-api-routes-inline-1776315686.png\"><\/p>\n<p>A server action is a function with <code>'use server'<\/code> at the top that you can pass directly to a <code>&lt;form action={...}&gt;<\/code>. Next compiles it into a POST endpoint, generates the wire protocol, and handles the FormData to function argument conversion for you. The <a href=\"https:\/\/nextjs.org\/docs\/app\/building-your-application\/data-fetching\/server-actions-and-mutations\" rel=\"nofollow noopener\" target=\"_blank\">official Next.js docs on server actions and mutations<\/a> cover the runtime details if you want to go deeper.<\/p>\n<p>Same feature, written with a server action:<\/p>\n<pre><code class=\"language-ts\">\/\/ app\/contact\/actions.ts\n'use server'\nimport { z } from 'zod'\nimport { saveContact } from '@\/lib\/db'\nimport { redirect } from 'next\/navigation'\n\nconst schema = z.object({\n  name: z.string().min(1),\n  email: z.string().email(),\n  message: z.string().min(10),\n})\n\nexport async function submitContact(_prev: unknown, formData: FormData) {\n  const parsed = schema.safeParse(Object.fromEntries(formData))\n  if (!parsed.success) {\n    return { error: parsed.error.flatten().fieldErrors }\n  }\n  await saveContact(parsed.data)\n  redirect('\/contact\/thanks')\n}\n<\/code><\/pre>\n<p>The form:<\/p>\n<pre><code class=\"language-tsx\">\/\/ app\/contact\/ContactForm.tsx\n'use client'\nimport { useActionState } from 'react'\nimport { submitContact } from '.\/actions'\n\nexport function ContactForm() {\n  const [state, formAction, pending] = useActionState(submitContact, null)\n  return (\n    &lt;form action={formAction}&gt;\n      &lt;input name=&quot;name&quot; \/&gt;\n      &lt;input name=&quot;email&quot; type=&quot;email&quot; \/&gt;\n      &lt;textarea name=&quot;message&quot; \/&gt;\n      &lt;button disabled={pending}&gt;{pending ? 'Sending\u2026' : 'Send'}&lt;\/button&gt;\n      {state?.error?.email &amp;&amp; &lt;p&gt;{state.error.email.join(', ')}&lt;\/p&gt;}\n    &lt;\/form&gt;\n  )\n}\n<\/code><\/pre>\n<p>No <code>fetch<\/code>. No JSON serialisation. No hand-rolled pending state. The types cross the client-server boundary on their own because the import is the real function signature, not a guess at a JSON shape.<\/p>\n<h2 id=\"the-useactionstate-hook-is-the-quiet-upgrade\">The useActionState hook is the quiet upgrade<\/h2>\n<p>The thing that finally sold me wasn&rsquo;t server actions on their own. It was <a href=\"https:\/\/react.dev\/reference\/react\/useActionState\" rel=\"nofollow noopener\" target=\"_blank\"><code>useActionState<\/code><\/a>. I spent years writing little <code>useState<\/code> hooks for &ldquo;is this form submitting right now,&rdquo; and half the time I&rsquo;d forget to reset them on unmount or on error. The hook bundles the previous result, the action wrapper, and the pending boolean into one call, and the pending flag is driven by the action itself rather than by me remembering to flip it.<\/p>\n<p>The piece I missed in the RFC: <code>useActionState<\/code> also plays nicely with progressive enhancement. If JavaScript hasn&rsquo;t loaded yet, the form still submits, because <code>&lt;form action={serverAction}&gt;<\/code> uses the native browser behaviour. The action still runs on the server. You get back a normal navigation. I tested this once with JS disabled in the browser and watched a form submit work. I was surprised, which is probably a sign I&rsquo;d written server actions off too quickly.<\/p>\n<h2 id=\"where-server-actions-tripped-me-up\">Where server actions tripped me up<\/h2>\n<p>Not all roses. Here are the actual cuts I got.<\/p>\n<p><code>redirect()<\/code> throws. It&rsquo;s implemented as a thrown exception so the framework can unwind the response cleanly. If you wrap a call in <code>try\/catch<\/code> to log errors, you&rsquo;ll accidentally swallow the redirect. The fix is to call <code>redirect<\/code> outside the try block, or to re-throw anything that isn&rsquo;t a normal error. I burned an afternoon on this before reading the small note about it in the docs.<\/p>\n<p>Cache invalidation is your job. After a mutation, stale server components will still show the old data until you call <code>revalidatePath('\/some\/route')<\/code> or <code>revalidateTag('posts')<\/code>. The failure mode is silent: your form succeeds, the DB updates, the UI shows the old row. I now keep a one-line checklist at the bottom of every action file that reminds me to pair writes with revalidation.<\/p>\n<pre><code class=\"language-ts\">await saveContact(parsed.data)\nrevalidatePath('\/admin\/contacts')\nredirect('\/contact\/thanks')\n<\/code><\/pre>\n<p>Auth and session still need thinking. Actions run on the server, yes, but they don&rsquo;t magically know who the user is. You still have to read the session or cookies at the top of each action. I wrote a small <code>withAuth(action)<\/code> wrapper after the third copy-paste.<\/p>\n<p>Input sizes matter. FormData uploads go through the action too. If you&rsquo;re taking file uploads, check the body size limit in your Next config. The default is reasonable for most forms, but the error you get when a client posts a 50MB PDF isn&rsquo;t obvious from the logs until you know what you&rsquo;re looking for.<\/p>\n<p>Error boundaries behave differently. If an action throws an unexpected error (not a redirect), Next surfaces it to the nearest <code>error.tsx<\/code>. If you don&rsquo;t have one on that route segment, the user gets the default fallback. I had a form once that quietly 500&rsquo;d for a week because I&rsquo;d written an <code>error.tsx<\/code> on the wrong segment. Put one at the segment that actually contains the form, or at the root, and test it by throwing a fake error in dev.<\/p>\n<h2 id=\"when-i-still-write-an-api-route\">When I still write an API route<\/h2>\n<p>Server actions replaced about 80% of my route handlers. The last 20% still belong to <code>\/app\/api\/*<\/code>, and I don&rsquo;t see that changing.<\/p>\n<p>Webhooks. Stripe, Clerk, a CRM pushing events. Anything where an external service owns the request format and expects a specific response code contract. Webhook handlers need signature verification and they aren&rsquo;t called from my own UI. That&rsquo;s a route handler, cleanly.<\/p>\n<p>Public API endpoints. If other teams or third-party clients hit your endpoint, you need a stable URL, a predictable JSON shape, and CORS behaviour you control. Server actions give you none of that. They&rsquo;re an implementation detail of your React tree, not a public contract.<\/p>\n<p>GET endpoints. Actions are POST-only by design. If you need cacheable reads (RSS feeds, sitemaps, public data endpoints) they go through route handlers or through server components fetching directly.<\/p>\n<p>Long-running or streaming responses. Actions can stream values back through <code>useActionState<\/code>, but if you&rsquo;re building a long-polling endpoint or a true SSE stream, the standard Response API inside a route handler is still the path of least surprise. For AI streaming specifically, if you&rsquo;re wiring up a local model or an OpenAI-style endpoint, I worked through some of the self-hosting tradeoffs in my <a href=\"https:\/\/abrarqasim.com\/blog\/open-source-llms-2026\" rel=\"noopener\">post on open source LLMs you can actually run in 2026<\/a>, and a route handler with a ReadableStream is still my default there.<\/p>\n<h2 id=\"what-id-tell-the-version-of-me-from-2023\">What I&rsquo;d tell the version of me from 2023<\/h2>\n<p>Three things I should have internalised earlier.<\/p>\n<p>The point isn&rsquo;t &ldquo;server actions are cleaner.&rdquo; The point is that the form, the validation, the DB write, and the redirect all live in one file you can reason about. A teammate who opens <code>actions.ts<\/code> sees everything that happens when the user clicks Submit. There&rsquo;s no client-server split to hold in your head while you debug.<\/p>\n<p>Second, <code>useActionState<\/code> is the hook you want even if you never write another action. The pending boolean and the bundled previous state alone clean up more code than I expected.<\/p>\n<p>Third, stop treating every mutation as a new endpoint. That mental model is left over from REST-shaped thinking. In a Next.js app, a mutation is a server function. Let it be one.<\/p>\n<p>If you want to try this tonight, take one form in your app, the one whose error handling you dread touching most, and port it. Keep the DB layer the same. Delete the <code>\/api\/*<\/code> route. Replace the client <code>fetch<\/code> with a server action and <code>useActionState<\/code>. Shouldn&rsquo;t take more than thirty minutes for a simple contact or signup form. If I&rsquo;d done it two years ago I&rsquo;d have saved myself a lot of typing, and probably a few bugs I never caught.<\/p>\n<p>I&rsquo;m collecting more before-and-after patterns like this in my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">client project notebook<\/a>. Happy to hear which ones you&rsquo;ve hit.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I resisted Next.js server actions for two years. Then I rebuilt a dashboard with them and dropped my \/api form routes. Here&#8217;s what worked and what I still skip.<\/p>\n","protected":false},"author":2,"featured_media":85,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I resisted Next.js server actions for two years. Then I rebuilt a dashboard with them and dropped my \/api form routes. Here's what worked and what I still skip.","rank_math_focus_keyword":"nextjs server actions","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[35],"tags":[44,61,41,62,63,39],"class_list":["post-87","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-web-development","tag-javascript","tag-nextjs","tag-react","tag-server-actions","tag-typescript","tag-web-development"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/87","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=87"}],"version-history":[{"count":1,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/87\/revisions"}],"predecessor-version":[{"id":88,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/87\/revisions\/88"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/85"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=87"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=87"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=87"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}