{"id":527,"date":"2026-07-31T05:02:09","date_gmt":"2026-07-31T05:02:09","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-two-rules-i-learned-the-hard-way\/"},"modified":"2026-07-31T05:02:09","modified_gmt":"2026-07-31T05:02:09","slug":"nextjs-server-actions-two-rules-i-learned-the-hard-way","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-two-rules-i-learned-the-hard-way\/","title":{"rendered":"Next.js Server Actions: Two Rules I Learned the Hard Way"},"content":{"rendered":"<p>I spent most of a Thursday convinced I&rsquo;d found a Next.js bug. I hadn&rsquo;t. I&rsquo;d found a paragraph in the docs that I had somehow never read, in a guide I would have sworn I&rsquo;d read twice.<\/p>\n<p>The setup was boring. A settings page with three toggles: dark mode, timezone, weekly digest. Each one saved independently. I wrapped the three saves in a <code>Promise.all<\/code> because that&rsquo;s what you do when you have three independent async things, and then I sat there watching the network tab show them go one, then the next, then the next. Neatly queued. Politely waiting their turn. Total time roughly the sum of all three.<\/p>\n<p>I blamed my database. I blamed the dev server. I restarted things. Eventually I read <a href=\"https:\/\/nextjs.org\/docs\/app\/guides\/server-actions\" rel=\"nofollow noopener\" target=\"_blank\">the Server Actions guide<\/a> properly and found the sentence that explains it, sitting right there under a heading called &ldquo;Sequential dispatch on the client.&rdquo;<\/p>\n<p>So here are the two rules I wish I&rsquo;d internalised a year ago. Neither is exotic. Both cost me real hours.<\/p>\n<h2 id=\"rule-one-server-actions-queue-one-at-a-time-per-client\">Rule one: server actions queue, one at a time, per client<\/h2>\n<p>Next.js dispatches Server Actions one at a time from a given client. Trigger three in quick succession and the second waits for the first, the third waits for the second. This isn&rsquo;t a quirk of my code, it&rsquo;s the client dispatcher doing it on purpose so the re-rendered server tree stays consistent with whichever action result produced it.<\/p>\n<p>That&rsquo;s a reasonable trade. It also means this is a lie:<\/p>\n<pre><code class=\"language-ts\">\/\/ Looks parallel. Is not parallel.\nawait Promise.all([\n  saveTheme('dark'),\n  saveTimezone('Asia\/Dubai'),\n  saveDigest(true),\n])\n<\/code><\/pre>\n<p>The fix is to stop treating actions like RPC endpoints and start treating them like transactions. One action, one mutation, one re-render:<\/p>\n<pre><code class=\"language-ts\">\/\/ app\/settings\/actions.ts\n'use server'\n\nimport { updateTag } from 'next\/cache'\nimport { auth } from '@\/lib\/auth'\nimport { db } from '@\/lib\/db'\n\nexport async function saveSettings(patch: SettingsPatch) {\n  const session = await auth()\n  if (!session?.user) throw new Error('Unauthorized')\n\n  await db.settings.update({\n    where: { userId: session.user.id },\n    data: patch,\n  })\n\n  updateTag(`settings-${session.user.id}`)\n}\n<\/code><\/pre>\n<p>If you genuinely need parallel work, do it inside a single action, or fetch in parallel from a Server Component, or use a Route Handler for anything that isn&rsquo;t a mutation. The docs say all three of those things. I just hadn&rsquo;t looked.<\/p>\n<p>The thing I keep having to say out loud to myself: a Server Action is <a href=\"https:\/\/react.dev\/reference\/rsc\/server-functions\" rel=\"nofollow noopener\" target=\"_blank\">a React Server Function<\/a> wired into React&rsquo;s action mechanism. It isn&rsquo;t a fetch call with nicer syntax. The queuing is the price of getting fresh UI back in the same response.<\/p>\n<h2 id=\"what-this-replaced-in-actual-code\">What this replaced, in actual code<\/h2>\n<p>I think the sequential thing only surprises people who came to actions from the API route era, which is most of us. It&rsquo;s worth putting the two side by side, because the &ldquo;after&rdquo; is genuinely smaller and that&rsquo;s the part that sold me.<\/p>\n<p>The old way. A route handler, plus a client component doing optimistic state by hand, plus a rollback you wrote at 11pm and never tested:<\/p>\n<pre><code class=\"language-tsx\">\/\/ app\/settings\/notification-toggle.tsx\n'use client'\nimport { useState } from 'react'\n\nexport function NotificationToggle({ initial }: { initial: boolean }) {\n  const [on, setOn] = useState(initial)\n  const [saving, setSaving] = useState(false)\n\n  async function toggle() {\n    setSaving(true)\n    setOn(!on)\n    const res = await fetch('\/api\/settings\/notifications', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application\/json' },\n      body: JSON.stringify({ enabled: !on }),\n    })\n    if (!res.ok) setOn(on) \/\/ roll back and hope\n    setSaving(false)\n  }\n\n  return (\n    &lt;button onClick={toggle} disabled={saving}&gt;\n      {on ? 'On' : 'Off'}\n    &lt;\/button&gt;\n  )\n}\n<\/code><\/pre>\n<p>Plus the handler itself, which is where the auth check lives:<\/p>\n<pre><code class=\"language-ts\">\/\/ app\/api\/settings\/notifications\/route.ts\nexport async function POST(req: Request) {\n  const session = await auth()\n  if (!session?.user) return new Response('Unauthorized', { status: 401 })\n\n  const { enabled } = await req.json()\n  await db.settings.update({\n    where: { userId: session.user.id },\n    data: { notifications: enabled },\n  })\n  return Response.json({ ok: true })\n}\n<\/code><\/pre>\n<p>Two files, a serialisation round trip, and client state that can drift from server state whenever the request fails in a way you didn&rsquo;t anticipate.<\/p>\n<p>The new way, using the action above:<\/p>\n<pre><code class=\"language-tsx\">'use client'\nimport { useTransition } from 'react'\nimport { saveSettings } from '.\/actions'\n\nexport function NotificationToggle({ on }: { on: boolean }) {\n  const [pending, startTransition] = useTransition()\n\n  return (\n    &lt;button\n      disabled={pending}\n      onClick={() =&gt; startTransition(() =&gt; saveSettings({ notifications: !on }))}\n    &gt;\n      {on ? 'On' : 'Off'}\n    &lt;\/button&gt;\n  )\n}\n<\/code><\/pre>\n<p>No local mirror of server state. No manual rollback. The action mutates, invalidates, and Next.js re-renders the route server side, shipping the new RSC payload back in the same response. For forms specifically I&rsquo;d reach for <code>useActionState<\/code> instead of <code>useTransition<\/code>, which I wrote about in <a href=\"https:\/\/abrarqasim.com\/blog\/react-19-useactionstate-the-form-boilerplate-i-deleted\" rel=\"noopener\">the post about deleting form boilerplate<\/a>.<\/p>\n<h2 id=\"rule-two-the-cache-call-you-pick-decides-whether-the-user-sees-their-own-change\">Rule two: the cache call you pick decides whether the user sees their own change<\/h2>\n<p>This is the one that actually shipped a bug for me, rather than just wasting an afternoon.<\/p>\n<p>There are four ways to update things after a mutation and they behave differently:<\/p>\n<p><code>updateTag<\/code> expires a tag immediately. The route re-render that rides along with the action&rsquo;s response waits for the fresh data. This is what you want when a user changes their own settings and expects to see them.<\/p>\n<p><code>revalidateTag<\/code> marks a tag stale and refreshes in the background. The action&rsquo;s own re-render does not wait. Fine for a blog index. Wrong for a settings page, which is exactly what I shipped.<\/p>\n<p><code>revalidatePath<\/code> invalidates by URL path. Useful when one route is affected and tagging is more machinery than the problem deserves. Worth reading <a href=\"https:\/\/nextjs.org\/docs\/app\/api-reference\/functions\/revalidatePath\" rel=\"nofollow noopener\" target=\"_blank\">the path caveats<\/a> if you use rewrites, because you have to pass the destination path, not the one in the address bar.<\/p>\n<p><code>refresh<\/code> refetches the current route&rsquo;s RSC payload without invalidating any cached data. For when the view depends on something outside the cache.<\/p>\n<p>My bug: user toggles the weekly digest, action calls <code>revalidateTag<\/code>, response comes back, toggle flips back to its old value for a second, then corrects itself. Users reported it as &ldquo;the setting doesn&rsquo;t save.&rdquo; It saved fine. The UI was just reading the stale value that stale-while-revalidate is designed to hand you. Swapping in <code>updateTag<\/code> fixed it in one line.<\/p>\n<h2 id=\"revalidatetag-grew-a-second-argument-in-16\">revalidateTag grew a second argument in 16<\/h2>\n<p>Related, and worth knowing before you upgrade: <code>revalidateTag<\/code> now requires a cache life profile as its second argument. The single-argument form is deprecated and will give you a TypeScript error.<\/p>\n<pre><code class=\"language-ts\">\/\/ Next.js 15\nrevalidateTag('posts')\n\n\/\/ Next.js 16\nrevalidateTag('posts', 'max')\n<\/code><\/pre>\n<p>You can pass a built-in profile like <code>max<\/code>, <code>hours<\/code> or <code>days<\/code>, or an inline custom one. The <a href=\"https:\/\/nextjs.org\/docs\/app\/guides\/upgrading\/version-16\" rel=\"nofollow noopener\" target=\"_blank\">version 16 upgrade guide<\/a> covers this alongside the other breaking changes, and the codemod handles most of the mechanical ones. It does not decide for you whether a given call site wanted <code>updateTag<\/code> instead. That&rsquo;s the judgement call, and it&rsquo;s the one worth making by hand.<\/p>\n<p>While you&rsquo;re in there: <code>middleware<\/code> is now <code>proxy<\/code>, Turbopack is the default for both dev and build, and synchronous <code>cookies()<\/code> and <code>params<\/code> access is fully gone rather than merely deprecated. I went through the App Router migration on this blog last year and wrote up <a href=\"https:\/\/abrarqasim.com\/blog\/nextjs-app-router-the-pages-router-migration-i-finally-finished\" rel=\"noopener\">what actually broke<\/a>, most of which was cache assumptions rather than routing.<\/p>\n<h2 id=\"the-bit-i-still-catch-myself-skipping\">The bit I still catch myself skipping<\/h2>\n<p>A Server Action is a POST endpoint. The <code>'use server'<\/code> directive swaps the implementation out of your client bundle and leaves behind an encrypted action ID that POSTs back. The implementation stays on the server, which is good, but the route is reachable by anyone who can send that POST.<\/p>\n<p>Next.js does give you an Origin\/Host CSRF check, a 1MB body limit by default, encrypted action IDs, and closure variable encryption. Useful. Not a substitute for checking who&rsquo;s calling.<\/p>\n<p>The failure mode I&rsquo;ve written more than once: accepting the whole object from the client.<\/p>\n<pre><code class=\"language-ts\">'use server'\n\n\/\/ Unsafe. The id comes from the client, so anyone who can POST here\n\/\/ can mark any row complete.\nexport async function completeItemUnsafe(item: Item) {\n  await db.item.update({ where: { id: item.id }, data: { completed: true } })\n}\n\n\/\/ Safe. Take the change, derive identity from the session, look up by ownership.\nexport async function completeItem(itemId: string) {\n  const session = await auth()\n  if (!session?.user) return\n\n  const item = await db.item.findFirst({\n    where: { id: itemId, ownerId: session.user.id },\n  })\n  if (!item) return\n\n  await db.item.update({ where: { id: item.id }, data: { completed: true } })\n}\n<\/code><\/pre>\n<p>Zod would happily validate the unsafe version. Schema validation checks shape, not ownership. A perfectly well formed object can still point at a row that isn&rsquo;t yours.<\/p>\n<p>If you&rsquo;re self hosting across multiple instances, set <code>NEXT_SERVER_ACTIONS_ENCRYPTION_KEY<\/code> to a stable value shared across them, otherwise closure decryption fails on whichever instance didn&rsquo;t build the bundle. I&rsquo;ve done deployment plumbing like this for a few client projects and it&rsquo;s usually the last thing anyone thinks of, so I&rsquo;ve started putting it in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">the setup notes I keep for these builds<\/a>.<\/p>\n<h2 id=\"what-id-do-this-week\">What I&rsquo;d do this week<\/h2>\n<p>Grep your codebase for <code>Promise.all<\/code> anywhere near an imported server action. If you find one, you have a serialised queue pretending to be parallel, and the fix is usually to merge those actions into one.<\/p>\n<p>Then grep for <code>revalidateTag<\/code>. For each call site, ask one question: does the person who just clicked need to see this change immediately? If yes, it should be <code>updateTag<\/code>. If it&rsquo;s a blog index or a product catalogue where a few seconds of stale is fine, leave it, but add the cache life profile before you upgrade to 16 or the build will complain.<\/p>\n<p>Both greps took me under ten minutes. The second one found two more instances of the bug I&rsquo;d already fixed once.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Server Actions in Next.js 16 queue one at a time, and revalidateTag no longer means what it used to. The two rules that cost me a Thursday, with code.<\/p>\n","protected":false},"author":2,"featured_media":526,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Server Actions in Next.js 16 queue one at a time, and revalidateTag no longer means what it used to. The two rules that cost me a Thursday, with code.","rank_math_focus_keyword":"nextjs server actions","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[35],"tags":[353,61,41,62,39],"class_list":["post-527","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-web-development","tag-caching","tag-nextjs","tag-react","tag-server-actions","tag-web-development"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/527","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=527"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/527\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/526"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=527"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=527"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=527"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}