React 19 Server Actions in Production: A Year of Lessons From a 4M-User App
What works, what bites, and how Server Actions changed the way our team writes mutations.
Table of Contents
- The PR that deleted 12,000 lines of API routes
- What Server Actions actually are
- What got better immediately
- What bit us
- Validation is your problem
- Error handling is awkward
- The N+1 mutation trap
- Caching and revalidation is the real frontier
- Auth context inside actions
- File uploads and large payloads
- The patterns that survived
- How Server Actions compare to alternatives
- What about React Query?
- Performance numbers from production
- Testing strategy for Server Actions
- A year of metrics worth sharing
- What this means for you
- FAQ
Table of Contents
- The PR that deleted 12,000 lines of API routes
- What Server Actions actually are
- What got better immediately
- What bit us
- Validation is your problem
- Error handling is awkward
- The N+1 mutation trap
- Caching and revalidation is the real frontier
- Auth context inside actions
- File uploads and large payloads
- The patterns that survived
- How Server Actions compare to alternatives
- What about React Query?
- Performance numbers from production
- Testing strategy for Server Actions
- A year of metrics worth sharing
- What this means for you
- FAQ
The PR that deleted 12,000 lines of API routes
In April 2025, I merged a PR that removed 12,387 lines of /api route handlers, tRPC procedures, and useMutation hooks. We replaced all of it with React 19 Server Actions on Next.js 15.4. Twelve months later, with 4 million monthly active users on the platform, I have a clear-eyed view of what that decision bought us and what it cost.
Short version: I would do it again. With caveats.
What Server Actions actually are
Server Actions, finalized in React 19 (December 2024), let you call a server function directly from a client component as if it were local. You annotate a function with "use server", import it, and call it. React handles the network, serialization, and revalidation.
For people who want to think better, not scroll more
Most people consume content. A few use it to gain clarity.
Get a curated set of ideas, insights, and breakdowns — that actually help you understand what’s going on.
No noise. No spam. Just signal.
One issue every Tuesday. No spam. Unsubscribe in one click.
"use server";
export async function updateProfile(formData: FormData) {
const name = formData.get("name") as string;
await db.user.update({ where: { id: userId }, data: { name } });
revalidatePath("/profile");
}That is the whole API. It is genuinely simpler than tRPC, REST, or GraphQL for the 80% case.
What got better immediately
A few wins showed up within the first sprint:
- Form code shrank by 60%. Pairing
useActionStatewith the nativeattribute removed a tower ofonSubmit,useState,isPending, anduseMutationboilerplate. - Progressive enhancement came back. Forms work without JavaScript. Our Lighthouse accessibility scores climbed from 89 to 97 across the funnel.
- Type safety end-to-end without codegen. Functions are imported, so TypeScript flows naturally. We deleted our entire OpenAPI generator pipeline.
- Smaller client bundles. Removing tRPC client code shaved roughly 38KB gzipped from our home route. LCP on slow 4G dropped 240ms.
What bit us
Now the honest part.
Validation is your problem
Server Actions do nothing for validation. You will validate input yourself. We standardized on Zod 4 with a thin wrapper:
export const action = createAction(
z.object({ email: z.email(), name: z.string().min(1) }),
async (input, ctx) => { /* ... */ }
);Without that wrapper, every action grew its own ad-hoc validation. After three of those, we wrote the wrapper. Do this on day one.
Error handling is awkward
Throwing inside a Server Action surfaces as a generic error to the client. You lose structured error info. The community pattern, and the one we adopted, is to return a typed result object instead of throwing:
return { ok: false, error: "EMAIL_TAKEN" } as const;Combined with useActionState, this gives clean per-field error rendering. But it means every action needs explicit return-type discipline.
The N+1 mutation trap
Server Actions feel cheap to call. They are not. Each one is a POST to your origin with a full RSC payload roundtrip. Inline-editing 50 list items by firing 50 actions in parallel will hammer your server. We caught this in load testing at 3,200 RPS. The fix is the same as REST: batch on the client, expose a bulkUpdate action.
Caching and revalidation is the real frontier
revalidatePath and revalidateTag are powerful and easy to misuse. Calling revalidatePath("/") from a settings action invalidates the entire site. We wrote a lint rule that flags broad revalidations and forces tag-based invalidation for anything beyond a single route.
Auth context inside actions
A subtle gotcha: Server Actions run on the server, but they are reachable by anyone who can call the function. They are public endpoints. We saw one team ship an admin-only deleteUser action without a permission check, assuming "it's only called from the admin page." It was not. A curious user found the action endpoint in the network tab and triggered it. Treat every Server Action as a public API endpoint and gate it accordingly. Our createAction wrapper now requires an explicit auth policy argument; you cannot create an action without declaring who can call it.
File uploads and large payloads
Server Actions serialize arguments as a multipart form payload, which makes File and FormData first-class citizens. That is genuinely nicer than JSON-encoded base64 in REST. But the platform default size limit is 1MB on Vercel, easy to bump but easy to forget. For multi-megabyte uploads we still use signed URLs to S3, then call a Server Action with just the resulting key. Server Actions are excellent for control-plane operations and awkward for raw data transfer.
The patterns that survived
After a year, these are the conventions that stuck:
- One action per file in
/app/_actions. Discoverable, easy to grep. - Typed return objects, never throws.
{ok: true, data}or{ok: false, error}. - Zod-validated inputs through a shared
createActionwrapper. useActionStatefor forms, plain async calls for non-form mutations.- Tag-based revalidation only. Path-based is too coarse at scale.
- Optimistic updates via
useOptimistic. Worth the API surface; users feel the difference.
How Server Actions compare to alternatives
| Approach | DX | Type safety | Bundle cost | Fits well for | |---|---|---|---|---| | Server Actions | Highest | Native | Lowest | Forms, mutations, internal apps | | tRPC v11 | High | Native | Medium | Complex client orchestration | | REST + OpenAPI | Medium | Via codegen | Medium | Public APIs, mobile clients | | GraphQL | Medium | Via codegen | Highest | Multi-consumer, federated graphs |
If your client is only your own Next.js app, Server Actions win. The moment you need to serve a mobile app or a third party, you still want a real API. We kept tRPC for our public partner endpoints.
What about React Query?
The most common question we get from teams considering this migration: do Server Actions kill TanStack Query? Mostly no. Server Actions handle mutations beautifully. Queries — especially client-driven, polling, infinite-scroll, or cache-heavy queries — still benefit from TanStack Query on top. A pragmatic split is RSC + Server Actions for reads that align with the page lifecycle, and TanStack Query for genuinely client-driven async state. Our app uses both, deliberately.
Performance numbers from production
Comparing the same checkout flow before (tRPC + REST hybrid) and after (Server Actions on Next.js 15.4):
- Median time-to-mutation-success: 412ms to 287ms
- p95 client JS for the route: 184KB to 121KB gzipped
- Form abandonment rate: 11.2% to 8.7%
- Cold-start function duration on Vercel: roughly equivalent, both around 90ms
The bundle and abandonment wins were larger than the latency win. That tracks: Server Actions remove client work, not server work.
Testing strategy for Server Actions
Testing was the question I had no good answer to for the first three months. The patterns that survived:
- Unit-test the action body, not the action. Extract the core logic into a plain async function that the action calls. Test that function with normal mocks. The "use server" wrapper is glue.
- Integration tests via Playwright. Real form submissions in a real browser. Slower, but catches the things unit tests cannot — serialization, auth, revalidation behavior.
- Type-level tests with
expectTypeOf. Server Actions are imported, so misuse shows up at compile time. Add typed contract tests where the return shape matters.
We deleted our entire MSW-based mock layer. With Server Actions there is no fetch to mock; the function is the function. The testing simplification alone justified the migration for our team.
A year of metrics worth sharing
Beyond the per-flow numbers above, the broader engineering health metrics moved in the right direction over twelve months:
- Average PR size in the web app: down 22%, because feature scaffolding takes less code.
- New-engineer time-to-first-merged-PR: from 9 days to 4 days. Less infrastructure to learn.
- Production incidents tagged "API" or "client/server contract": down 38%.
Correlation, not causation, but the direction is consistent.
What this means for you
If you are starting a Next.js project in 2026, default to Server Actions for mutations. Do not build a parallel API layer until you have a second consumer.
If you have an existing tRPC or REST codebase, do not migrate everything. Migrate one feature, get the patterns right, then expand. Our migration took six months and we still have a small /api surface for webhooks, mobile, and partners.
The biggest mindset shift: stop thinking about endpoints. Start thinking about server functions you can call from anywhere in your component tree. That is the actual upgrade React 19 delivered, and it changes how you design features more than any individual API improvement of the last five years.
One closing observation: Server Actions have changed how new engineers on our team learn the codebase. They no longer have to mentally trace a click through a fetch hook, an API route handler, a controller, and a service. They follow an import. Onboarding documentation that used to need a sequence diagram now needs a paragraph. That alone is worth more than any of the performance numbers above. The best frameworks make the right thing the obvious thing, and for the first time in years, doing mutations the right way in React feels like the path of least resistance instead of the path of most ceremony.
FAQ
💡 Key Takeaways
- In April 2025, I merged a PR that removed 12,387 lines of `/api` route handlers, tRPC procedures, and `useMutation` hooks.
- Server Actions, finalized in React 19 (December 2024), let you call a server function directly from a client component as if it were local.
- await db.
Ask AI About This Topic
Get instant answers trained on this exact article.
Frequently Asked Questions
Nilesh Kasar
Community MemberAn active community contributor shaping discussions on Development.
You Might Also Like
Enjoying this story?
Get more in your inbox
Join 12,000+ readers who get the best stories delivered daily.
Subscribe to The Stack Stories →Nilesh Kasar
Community MemberAn active community contributor shaping discussions on Development.
The Stack Stories
One thoughtful read, every Tuesday.
Responses
Join the conversation
You need to log in to read or write responses.
No responses yet. Be the first to share your thoughts!