{"id":404,"date":"2026-07-01T05:04:34","date_gmt":"2026-07-01T05:04:34","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-when-i-stopped-writing-api-routes\/"},"modified":"2026-07-01T05:04:34","modified_gmt":"2026-07-01T05:04:34","slug":"nextjs-server-actions-when-i-stopped-writing-api-routes","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-when-i-stopped-writing-api-routes\/","title":{"rendered":"Next.js Server Actions: When I Stopped Writing API Routes"},"content":{"rendered":"<p>Short version for the impatient: I rewrote a side project&rsquo;s checkout flow last weekend and deleted seven <code>app\/api\/...<\/code> 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&rsquo;t see coming. If you want to know why I made the switch (and where I still keep API routes around), read on.<\/p>\n<p>I&rsquo;m not going to pretend Server Actions are some revolution. They&rsquo;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&rsquo;t, and the specific footguns I keep hitting. This is that writeup.<\/p>\n<h2 id=\"the-mental-shift-i-had-to-make\">The mental shift I had to make<\/h2>\n<p>The thing that finally clicked for me: a Server Action isn&rsquo;t an API endpoint that happens to live next to your component. It&rsquo;s an RPC call dressed up as a function. You write a function on the server, mark it with <code>\"use server\"<\/code>, and the client gets a generated POST stub that calls it for you.<\/p>\n<p>Here&rsquo;s what mutating data looked like in my Next.js 13 codebase, before I switched:<\/p>\n<pre><code class=\"language-ts\">\/\/ app\/api\/notes\/route.ts\nimport { NextResponse } from 'next\/server'\nimport { auth } from '@\/lib\/auth'\nimport { db } from '@\/lib\/db'\n\nexport async function POST(req: Request) {\n  const session = await auth()\n  if (!session) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })\n\n  const body = await req.json()\n  const title = String(body.title ?? '').trim()\n  if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 })\n\n  const note = await db.note.create({\n    data: { title, userId: session.user.id }\n  })\n  return NextResponse.json(note)\n}\n<\/code><\/pre>\n<p>And the client side:<\/p>\n<pre><code class=\"language-tsx\">\/\/ app\/notes\/NewTodo.tsx\n'use client'\nimport { useState } from 'react'\n\nexport function NewTodo() {\n  const [title, setTitle] = useState('')\n  const [pending, setPending] = useState(false)\n\n  async function submit(e: React.FormEvent) {\n    e.preventDefault()\n    setPending(true)\n    const res = await fetch('\/api\/notes', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application\/json' },\n      body: JSON.stringify({ title })\n    })\n    setPending(false)\n    if (res.ok) setTitle('')\n  }\n\n  return (\n    &lt;form onSubmit={submit}&gt;\n      &lt;input value={title} onChange={e =&gt; setTitle(e.target.value)} \/&gt;\n      &lt;button disabled={pending}&gt;Add&lt;\/button&gt;\n    &lt;\/form&gt;\n  )\n}\n<\/code><\/pre>\n<p>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&rsquo;t know what the server returns; if I rename <code>title<\/code> to <code>name<\/code> on the server, the client compiles cleanly and breaks at runtime.<\/p>\n<p>Here&rsquo;s the same thing with a Server Action:<\/p>\n<pre><code class=\"language-tsx\">\/\/ app\/notes\/actions.ts\n'use server'\nimport { auth } from '@\/lib\/auth'\nimport { db } from '@\/lib\/db'\nimport { revalidatePath } from 'next\/cache'\n\nexport async function createTodo(formData: FormData) {\n  const session = await auth()\n  if (!session) throw new Error('unauthorized')\n\n  const title = String(formData.get('title') ?? '').trim()\n  if (!title) throw new Error('title required')\n\n  await db.note.create({\n    data: { title, userId: session.user.id }\n  })\n  revalidatePath('\/notes')\n}\n<\/code><\/pre>\n<pre><code class=\"language-tsx\">\/\/ app\/notes\/NewTodo.tsx\nimport { createTodo } from '.\/actions'\n\nexport function NewTodo() {\n  return (\n    &lt;form action={createTodo}&gt;\n      &lt;input name=&quot;title&quot; \/&gt;\n      &lt;button&gt;Add&lt;\/button&gt;\n    &lt;\/form&gt;\n  )\n}\n<\/code><\/pre>\n<p>No client component. No <code>useState<\/code>. No fetch. The form&rsquo;s <code>action<\/code> 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&rsquo;s the whole pitch in one diff.<\/p>\n<h2 id=\"what-actually-replaced-api-routes-for-me\">What actually replaced API routes for me<\/h2>\n<p>I didn&rsquo;t go nuclear and delete every route handler. I made a rule for myself: a Server Action is right when the call is <strong>internal<\/strong>, <strong>mutating<\/strong>, and <strong>co-located<\/strong> with the component that calls it. An API route is right when any of those three break.<\/p>\n<p>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&rsquo;re meant to be called by the framework&rsquo;s generated stubs. If a mobile app or a Zapier webhook needs to hit the same logic, that&rsquo;s an API route (or a tRPC procedure, which I covered in <a href=\"https:\/\/abrarqasim.com\/blog\/trpc-in-2026-the-type-safe-api-i-actually-reach-for\/\" rel=\"noopener\">my tRPC writeup<\/a>).<\/p>\n<p>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 <code>getUser()<\/code> and it makes me twitch. They&rsquo;re called via POST, they bypass the React cache, and you lose streaming. Just <code>await<\/code> the query in the Server Component.<\/p>\n<p>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.<\/p>\n<p>The Next.js docs are direct about this. <a href=\"https:\/\/nextjs.org\/docs\/app\/building-your-application\/data-fetching\/server-actions-and-mutations\" rel=\"nofollow noopener\" target=\"_blank\">The official Server Actions guide<\/a> calls them &ldquo;asynchronous functions that are executed on the server&rdquo; and explicitly frames them as a tool for mutations and form submissions. They are not a replacement for your whole backend.<\/p>\n<h2 id=\"the-progressive-enhancement-bit-that-surprised-me\">The progressive enhancement bit that surprised me<\/h2>\n<p>I&rsquo;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 &ldquo;degraded gracefully&rdquo;. Actually worked. Submitted the form, ran the action, redirected back, showed the new note.<\/p>\n<p>That&rsquo;s because passing a server function to <code>form action<\/code> produces real HTML that posts to a real URL. The client JS hijacks it when it&rsquo;s available and turns it into a fetch, but the underlying form is a form. This is the part of React 19&rsquo;s design that I think is undersold. The <a href=\"https:\/\/react.dev\/reference\/react\/useActionState\" rel=\"nofollow noopener\" target=\"_blank\">React Actions documentation<\/a> treats this as the default, not an edge case.<\/p>\n<p>For a content site or an admin dashboard where users might have flaky connections, this is genuinely useful. For a SPA-feel product, it&rsquo;s a nice-to-have. But I stopped writing client components for forms unless I have a real reason \u2014 most of mine don&rsquo;t need state at all.<\/p>\n<h2 id=\"pending-ui-without-writing-pending-ui\">Pending UI without writing pending UI<\/h2>\n<p>The pending state I used to track with <code>useState<\/code> is now <code>useFormStatus<\/code>. You drop it inside the form and it tells you whether the action is in flight:<\/p>\n<pre><code class=\"language-tsx\">'use client'\nimport { useFormStatus } from 'react-dom'\n\nexport function SubmitButton() {\n  const { pending } = useFormStatus()\n  return (\n    &lt;button disabled={pending}&gt;\n      {pending ? 'Adding\\u2026' : 'Add'}\n    &lt;\/button&gt;\n  )\n}\n<\/code><\/pre>\n<p>The gotcha: <code>useFormStatus<\/code> reads from the nearest parent <code>&lt;form&gt;<\/code>. 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 <code>&lt;form&gt;<\/code>, and <code>pending<\/code> was always false. Five minutes of staring before I read the docs properly. The hook is reading the wrong form&rsquo;s context, because it&rsquo;s the form&rsquo;s <em>parent<\/em>.<\/p>\n<p>For optimistic UI I reach for <code>useOptimistic<\/code>, which I wrote about in detail in my post on <a href=\"https:\/\/abrarqasim.com\/blog\/useoptimistic-react-optimistic-ui-i-stopped-hand-rolling\/\" rel=\"noopener\">optimistic UI I stopped hand-rolling<\/a>. The pair is meant to be used together: <code>useFormStatus<\/code> for the spinner, <code>useOptimistic<\/code> for the optimistic update, and the Server Action for the actual write.<\/p>\n<h2 id=\"the-error-handling-story-is-rougher-than-id-like\">The error handling story is rougher than I&rsquo;d like<\/h2>\n<p>This is the part where I admit I&rsquo;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&rsquo;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.<\/p>\n<p>The pattern I&rsquo;ve settled on uses <code>useActionState<\/code>:<\/p>\n<pre><code class=\"language-tsx\">\/\/ actions.ts\n'use server'\nexport type CreateTodoState = { error?: string } | { ok: true }\n\nexport async function createTodo(\n  _prev: CreateTodoState,\n  formData: FormData\n): Promise&lt;CreateTodoState&gt; {\n  const title = String(formData.get('title') ?? '').trim()\n  if (!title) return { error: 'Title is required' }\n  if (title.length &gt; 200) return { error: 'Title too long' }\n\n  try {\n    await db.note.create({ data: { title, userId: '...' } })\n  } catch (e) {\n    return { error: 'Could not save. Try again?' }\n  }\n  return { ok: true }\n}\n<\/code><\/pre>\n<pre><code class=\"language-tsx\">\/\/ NewTodo.tsx\n'use client'\nimport { useActionState } from 'react'\nimport { createTodo } from '.\/actions'\n\nexport function NewTodo() {\n  const [state, formAction] = useActionState(createTodo, { ok: true } as any)\n  return (\n    &lt;form action={formAction}&gt;\n      &lt;input name=&quot;title&quot; \/&gt;\n      &lt;button&gt;Add&lt;\/button&gt;\n      {'error' in state &amp;&amp; state.error &amp;&amp; &lt;p role=&quot;alert&quot;&gt;{state.error}&lt;\/p&gt;}\n    &lt;\/form&gt;\n  )\n}\n<\/code><\/pre>\n<p>It works. It&rsquo;s also more ceremony than I&rsquo;d want for every form. I&rsquo;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&rsquo;re already using Zod with <a href=\"https:\/\/abrarqasim.com\/blog\/zod-validation-the-runtime-types-i-stopped-hand-checking\/\" rel=\"noopener\">the validation patterns I covered for runtime types<\/a>, this is the natural extension.<\/p>\n<p>For unexpected errors (database down, third-party API timing out), I let them throw and catch them at the route&rsquo;s <code>error.tsx<\/code> boundary. That&rsquo;s worked fine. The split is: expected user-input errors return state, infrastructure failures throw.<\/p>\n<h2 id=\"security-what-you-actually-have-to-think-about\">Security: what you actually have to think about<\/h2>\n<p>Next.js encrypts the action ID and includes a CSRF check when the request origin doesn&rsquo;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.<\/p>\n<p>Every Server Action runs your code with whatever permissions the request has. There&rsquo;s no automatic &ldquo;is this user allowed to do this&rdquo; gate. If you ship a <code>deleteTodo(id)<\/code> action without checking <code>session.user.id === note.userId<\/code>, anybody with an authenticated session can delete anybody&rsquo;s notes by sending the right form data. I caught this in code review on a project last quarter \u2014 the original author assumed the action being &ldquo;server-only&rdquo; meant it was somehow protected. It is not. It&rsquo;s a POST endpoint that anyone with a logged-in session can hit.<\/p>\n<p>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 <a href=\"https:\/\/nextjs.org\/blog\/security-nextjs-server-components-actions\" rel=\"nofollow noopener\" target=\"_blank\">Next.js security docs for Server Actions<\/a> call this out specifically, and it&rsquo;s the first thing I review when somebody adds a new action to one of my projects.<\/p>\n<h2 id=\"where-i-still-keep-api-routes\">Where I still keep API routes<\/h2>\n<p>Four cases, in order of how often they come up for me:<\/p>\n<p>Webhooks. Stripe, Resend, Clerk, whatever. They&rsquo;re external callers, they need a stable URL, they need to verify a signature against a raw body, and they don&rsquo;t fit the Server Action pattern at all. These stay as route handlers.<\/p>\n<p>Public JSON APIs for a mobile client or partner integration. Same logic \u2014 Server Actions aren&rsquo;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.<\/p>\n<p>Long-running streaming responses. SSE for a chat UI, for example. Server Actions are POST request\/response; they don&rsquo;t stream a body back. Route handlers with <code>ReadableStream<\/code> do.<\/p>\n<p>File downloads with custom headers. Generating a CSV with <code>Content-Disposition: attachment<\/code>, signing a PDF, that kind of thing. The action wire protocol is not built for this.<\/p>\n<p>Everything else \u2014 the form submissions, the toggle buttons, the &ldquo;add to cart&rdquo; clicks, the comment posts \u2014 I write as Server Actions now. If you want a sense of the kind of thing I build with this setup, I keep <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">a few of these patterns documented in my work<\/a>.<\/p>\n<h2 id=\"one-concrete-thing-to-try-this-week\">One concrete thing to try this week<\/h2>\n<p>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&rsquo;ll probably want <code>useActionState<\/code> for the error display).<\/p>\n<p>If the experience is good, you&rsquo;ll find yourself doing it to the next form, and the one after that. If it isn&rsquo;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.<\/p>\n<p>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&rsquo;t reversed any of those decisions, and that&rsquo;s the closest thing to a recommendation I&rsquo;m willing to make.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>How I stopped writing Next.js API routes for mutations, what Server Actions actually replaced, the bugs they caused me, and where I still keep API routes around.<\/p>\n","protected":false},"author":2,"featured_media":403,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"How I stopped writing Next.js API routes for mutations, what Server Actions actually replaced, the bugs they caused me, and where I still keep API routes around.","rank_math_focus_keyword":"nextjs server actions","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[388,354,35],"tags":[438,61,41,359,170,136,172],"class_list":["post-404","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-next-js","category-react","category-web-development","tag-next-js","tag-nextjs","tag-react","tag-react-19-2","tag-server-actions-2","tag-useactionstate","tag-web-development-2"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/404","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=404"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/404\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/403"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=404"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=404"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=404"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}