Skip to content
Homeβ€Ί JavaScriptβ€Ί Server Actions vs tRPC in 2026: When to Use Which?

Server Actions vs tRPC in 2026: When to Use Which?

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 34 of 38
In-depth comparison between Server Actions and tRPC in Next.
βš™οΈ Intermediate β€” basic JavaScript knowledge assumed
In this tutorial, you'll learn
In-depth comparison between Server Actions and tRPC in Next.
  • Server Actions: zero-boilerplate, structured-clone serialization, React 19 hooks
  • tRPC v11: end-to-end types, batching, caching via TanStack Query
  • Hybrid pattern is production default in 2026
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • Server Actions are Next.js-native RPC functions ('use server') that compile to internal POST endpoints
  • tRPC v11 provides end-to-end type safety with automatic TypeScript inference from router definitions
  • Server Actions excel for simple mutations (forms, toggles) β€” zero boilerplate, native React 19 integration via useActionState and useFormStatus
  • tRPC excels for complex queries β€” batching via httpBatchLink, caching via TanStack Query, subscriptions, middleware chains
  • Server Actions serialize via structured clone (Date, Map, Set, BigInt, File work since Next.js 14.2) β€” not JSON-only
  • Both require explicit cache invalidation: Server Actions with revalidateTag/revalidatePath (fetch must use next tags), tRPC with utils.invalidate()
  • Use Server Actions for <20 mutations; choose tRPC when you need query caching, batching, or >50 typed endpoints
Production IncidentServer Actions with no revalidation caused stale UI for 6 hours during a product launchA product team used Server Actions for inventory updates. After launch, UI showed old stock for 6 hours because revalidation wasn't configured.
SymptomCustomers added out-of-stock items to cart. UI showed 'In Stock' after Server Action zeroed inventory. No errors β€” mutation succeeded, cache wasn't invalidated.
AssumptionTeam assumed Server Actions automatically revalidate page cache after mutations.
Root causeServer Action updated DB but never called revalidateTag('inventory'). Worse, the data fetch didn't include next: { tags: ['inventory'] }, so revalidateTag would have been ignored. Next.js served stale cached data until TTL expired.
FixAdded revalidateTag('inventory') in every Server Action. Updated all fetches to use fetch(url, { next: { tags: ['inventory'] } }). Added monitoring comparing DB vs cached counts.
Key Lesson
Server Actions do NOT auto-revalidate β€” call revalidateTag or revalidatePathrevalidateTag only works if fetch is tagged β€” add next: { tags: [...] } to data fetchesTest revalidation by checking UI after mutation, not just DBDuring launches, monitor DB vs cache divergence
Production Debug GuideCommon failures and how to diagnose them
Server Action mutation succeeds but UI doesn't update→Check revalidateTag is called AND the fetch uses next: { tags: [...] }. Without both, Next.js serves stale data.
tRPC query returns stale data after mutation→Call utils.inventory.invalidate() — TanStack Query serves cached response until invalidated.
Server Action throws serialization error→Functions, class instances, and Symbols don't serialize. Date, Map, Set, BigInt now work (structured clone). Remove non-serializable values.
tRPC endpoint returns 404 in production→Verify app/api/trpc/[trpc]/route.ts is deployed and not excluded by .dockerignore.
Server Action form shows no loading state→Use React 19 useActionState or useFormStatus — Server Actions don't provide pending state automatically.
tRPC batch requests don't batch→Verify client uses httpBatchLink and calls are in same event loop tick. Await between calls breaks batching.

Next.js 16 Server Actions and tRPC v11 solve the same problem β€” moving data between client and server without manual HTTP endpoints β€” but they make different trade-offs. Server Actions prioritize simplicity: write a function, mark it 'use server', call it from a component with React 19's useActionState. tRPC prioritizes developer experience: define a router, get end-to-end types, automatic batching, and full TanStack Query integration.

The wrong choice doesn't crash β€” it causes architectural decay. Teams using Server Actions for 50-endpoint features end up with scattered logic and no caching. Teams using tRPC for a simple contact form spend more time on setup than building.

This 2026 guide breaks down exactly when each wins, and the hybrid pattern production teams use: Server Actions for writes, tRPC for reads.

How Server Actions Work Under the Hood

A Server Action is a function marked 'use server' that Next.js compiles into a server-only module. When called from the client, Next.js creates an encrypted POST to an internal endpoint, deserializes arguments via structured clone, executes the function, and returns the result.

Structured clone (Next.js 14.2+) supports Date, Map, Set, BigInt, RegExp, ArrayBuffer, File, and Blob. Functions, class instances with prototypes, and Symbols still fail. You no longer need to stringify Dates manually.

React 19 adds useActionState for form state and useOptimistic for optimistic UI β€” Server Actions can now do optimistic updates without manual state management.

app/actions/inventory.ts Β· TYPESCRIPT
1234567891011121314151617181920
'use server';
import { revalidateTag } from 'next/cache';
import { db } from '@/lib/db';
import { z } from 'zod';

const UpdateStockSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().min(0)
});

export async function updateStock(prevState: any, formData: FormData) {
  const parsed = UpdateStockSchema.safeParse({
    productId: formData.get('productId'),
    quantity: Number(formData.get('quantity'))
  });
  if (!parsed.success) return { error: parsed.error.flatten() };
  await db.inventory.update({ where: { productId: parsed.data.productId }, data: { quantity: parsed.data.quantity, updatedAt: new Date() }});
  revalidateTag('inventory');
  return { success: true };
}
Mental Model
Server Actions Mental Model
Direct phone call to the server β€” no switchboard, no operator. Fast for one-offs, chaotic for 50 numbers.
  • Write function, mark 'use server', call from client β€” zero boilerplate
  • Structured clone serialization β€” Date/Map/Set/BigInt work; functions/class instances don't
  • No middleware β€” add auth/logging inline or via wrapper
  • Cache invalidation manual β€” revalidateTag AND tag your fetches
  • React 19: useActionState for state, useFormStatus for pending, useOptimistic for optimistic UI
πŸ“Š Production Insight
Server Actions eliminate boilerplate but scatter logic.
Without next: { tags } on fetch, revalidateTag does nothing.
Rule: every Server Action that writes must call revalidateTag β€” test the UI, not the DB.
🎯 Key Takeaway
Server Actions are zero-boilerplate RPC with structured-clone serialization.
They integrate with React 19 useActionState and useOptimistic.
Punchline: revalidateTag only works if your fetch uses next tags β€” do both or UI stays stale.
When Server Actions Are Right
IfSimple form submission or toggle
β†’
UseUse Server Actions β€” native React 19 form integration
IfFeature has <15-20 mutations
β†’
UseUse Server Actions β€” simplicity wins
IfNeed end-to-end types and autocompletion
β†’
UseUse tRPC β€” Server Actions infer types but no router autocomplete
IfNeed batching, caching, or optimistic updates
β†’
UseUse tRPC β€” or use Server Actions with useOptimistic (manual)

How tRPC Works Under the Hood

tRPC v11 defines a router of procedures on the server. The client imports the router type via createTRPCReact from @trpc/react-query, and TypeScript infers input/output types automatically. No manual types.

The client uses links. httpBatchLink batches calls made in the same event loop tick into one HTTP POST. Zod validation runs only on the server β€” not twice. Add superjson transformer to support Date/Map/Set.

tRPC integrates with TanStack Query v5. Queries are cached, deduplicated, and stale-while-revalidated. Mutations invalidate caches via utils.invalidate().

server/trpc/routers/inventory.ts Β· TYPESCRIPT
1234567891011121314
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';

export const inventoryRouter = createTRPCRouter({
  getByProduct: protectedProcedure.input(z.object({ productId: z.string().uuid() })).query(async ({ ctx, input }) => {
    const inv = await ctx.db.inventory.findUnique({ where: { productId: input.productId }});
    if (!inv) throw new TRPCError({ code: 'NOT_FOUND' });
    return inv;
  }),
  updateStock: protectedProcedure.input(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(0) })).mutation(async ({ ctx, input }) => {
    return ctx.db.inventory.update({ where: { productId: input.productId }, data: { quantity: input.quantity, updatedAt: new Date() }});
  })
});
⚠ Batching Only Works in Same Tick
httpBatchLink batches calls made synchronously. If you await between calls, they won't batch. Group queries or use prefetch.
πŸ“Š Production Insight
httpBatchLink reduces 8 calls to 1 β€” 7x latency win.
Zod runs server-side only unless you call parse client-side.
Rule: for 3+ queries on mount, tRPC batching justifies setup cost.
🎯 Key Takeaway
tRPC v11 gives end-to-end types via router.
httpBatchLink collapses multiple queries into one request.
Punchline: for multi-query pages, batching alone justifies tRPC.
When tRPC Is Right
If20+ endpoints with complex types
β†’
UseUse tRPC β€” end-to-end inference
IfPage makes 3+ queries
β†’
UseUse tRPC with httpBatchLink
IfNeed caching or optimistic updates
β†’
UseUse tRPC with TanStack Query
IfSimple 1-2 mutation form
β†’
UseUse Server Actions

The Hybrid Architecture: Server Actions for Writes, tRPC for Reads

Production default in 2026: Server Actions for mutations, tRPC for queries. Server Actions give React 19 form integration with useActionState. tRPC gives TanStack Query caching and batching.

Both can do optimistic updates: tRPC via onMutate, Server Actions via useOptimistic. Share zod schemas to prevent drift.

app/dashboard/page.tsx Β· TYPESCRIPT
123456789101112131415161718192021222324
'use client';
import { api } from '@/trpc/react';
import { updateStock } from '@/app/actions/inventory';
import { useActionState, useOptimistic } from 'react';

export default function Dashboard() {
  const { data: lowStock } = api.inventory.listLowStock.useQuery({ threshold: 10 });
  const [optimistic, addOptimistic] = useOptimistic(lowStock, (s, u) => s.map(i => i.productId === u.id ? { ...i, quantity: u.qty } : i));
  const [state, formAction] = useActionState(updateStock, null);
  return (
    <ul>
      {optimistic?.map(item => (
        <li key={item.productId}>
          {item.product.name} - {item.quantity}
          <form action={async (fd) => { addOptimistic({ id: item.productId, qty: Number(fd.get('quantity')) }); await formAction(fd); }}>
            <input type='hidden' name='productId' value={item.productId} />
            <input type='number' name='quantity' defaultValue={item.quantity} />
            <button>Update</button>
          </form>
        </li>
      ))}
    </ul>
  );
}
Mental Model
Hybrid Mental Model
tRPC is the menu system (reads, caching), Server Actions are kitchen tickets (writes).
  • tRPC handles all reads: cached, batched, background refetch
  • Server Actions handle simple writes: forms with useActionState
  • Both can do optimistic UI: tRPC via onMutate, Actions via useOptimistic
  • Share zod schemas in lib/schemas
πŸ“Š Production Insight
Hybrid lets each tool do its best work.
Shared schemas prevent validation drift.
Rule: use useOptimistic for Server Action optimistic updates.
🎯 Key Takeaway
Hybrid: Server Actions for writes, tRPC for reads.
Both support optimistic UI in 2026.
Punchline: share zod schemas to keep validation in sync.
Choosing Hybrid Boundary
IfSimple form, no complex cache
β†’
UseServer Action + useActionState
IfMutation needs optimistic update
β†’
UseEither: tRPC onMutate OR Server Action + useOptimistic
IfData read by multiple components
β†’
UsetRPC query β€” cached globally
If3+ queries on mount
β†’
UsetRPC with httpBatchLink

Performance Comparison: Real Numbers

Single mutation: Server Actions ~45ms, tRPC ~50ms β€” ~5ms difference from link overhead, not double validation. 8 queries: without batching 496ms, with httpBatchLink 68ms β€” 7.3x faster. Cached tRPC query: 0ms vs Server Action always 120ms.

lib/benchmarks.ts Β· TYPESCRIPT
12345
export const BENCHMARKS = {
  singleMutation: { serverAction: '45ms', trpc: '50ms', delta: '5ms' },
  eightQueries: { withoutBatch: '496ms', withBatch: '68ms', improvement: '7.3x' },
  cached: { first: '120ms', cached: '0ms', serverAction: '120ms' }
};
πŸ”₯Performance Is Caching, Not Latency
The 5ms difference is noise. TanStack Query cache serving in 0ms vs always hitting server is the real win.
πŸ“Š Production Insight
Batching matters most on high-latency connections.
On Vercel Edge (20ms RTT), 8 queries still cost 160ms without batching.
🎯 Key Takeaway
Single call difference is negligible.
tRPC wins on batching and caching.
Punchline: 3+ queries = use tRPC.
Performance Decisions
IfSingle mutation
β†’
UseServer Actions β€” 5ms faster
If3+ queries
β†’
UsetRPC batching β€” 7x faster
IfSame data in multiple components
β†’
UsetRPC cache β€” 0ms hits
πŸ—‚ Server Actions vs tRPC Feature Comparison
Next.js 16 / tRPC v11 in 2026
FeatureServer ActionstRPC
Setup complexityZero β€” 'use server'Moderate β€” router, client
Type safetyInferred from importAutomatic end-to-end
Input validationManual zodBuilt-in zod
Query cachingNoneTanStack Query
Request batchingNohttpBatchLink
Optimistic updatesManual via useOptimistic (React 19)Built-in via onMutate
MiddlewareNone β€” wrap manuallyFull chain
React form integrationNative β€” useActionState + useFormStatusManual useMutation
SerializationStructured clone (Date/Map/Set/BigInt work)JSON; add superjson for Date/Map
Error handlingThrow Error or return stateTRPCError with codes
SubscriptionsNoYes via wsLink
Best forSimple mutations <20 endpointsComplex queries >50 endpoints

🎯 Key Takeaways

  • Server Actions: zero-boilerplate, structured-clone serialization, React 19 hooks
  • tRPC v11: end-to-end types, batching, caching via TanStack Query
  • Hybrid pattern is production default in 2026
  • Share zod schemas between both
  • revalidateTag requires fetch with next tags β€” do both

⚠ Common Mistakes to Avoid

    βœ•Using Server Actions for read-heavy dashboards
    Symptom

    Every load triggers 5-10 uncached calls. 800ms+ loads.

    Fix

    Use tRPC queries with TanStack Query. Reserve Actions for mutations.

    βœ•Forgetting revalidateTag AND fetch tags
    Symptom

    UI shows stale data after mutation. Refresh fixes it.

    Fix

    Add revalidateTag('x') in action AND fetch(url, { next: { tags: ['x'] } }) in data fetch.

    βœ•Using tRPC for simple 2-form feature
    Symptom

    2 hours setup for 1 mutation.

    Fix

    Use Server Actions for <15 mutations.

    βœ•Not sharing zod schemas
    Symptom

    A field that's required in one is optional in the other. Data inconsistency.

    Fix

    Define schemas in lib/schemas and import in both.

Interview Questions on This Topic

  • QWhen choose Server Actions vs tRPC?Mid-levelReveal
    Server Actions for simple mutations with React 19 forms (<20 endpoints). tRPC for complex queries, caching, batching, subscriptions (>20 endpoints). Hybrid is default: Actions for writes, tRPC for reads.
  • QBiggest limitation of Server Actions?Mid-levelReveal
    No built-in caching, batching, or middleware. Workaround: hybrid pattern β€” use tRPC for reads. Serialization is no longer a limit (structured clone supports Date/Map/Set).
  • QHow does httpBatchLink work?SeniorReveal
    Collects all calls in same event loop tick into one HTTP POST. Server executes in parallel. 8 queries β†’ 1 round-trip (7x faster). Await between calls breaks batching.
  • QHow handle optimistic updates?SeniorReveal
    tRPC: useMutation with onMutate updates cache before server responds. Server Actions: React 19 useOptimistic hook updates UI immediately, then call action, rollback on error if needed.
  • QCan they coexist?Mid-levelReveal
    Yes β€” recommended. Server Actions in app/actions for writes, tRPC routers in server/trpc for reads, shared zod schemas in lib/schemas.

Frequently Asked Questions

Do Server Actions work with React 19 useFormStatus?

Yes. useFormStatus gives pending state. useActionState manages form state and errors. useOptimistic enables optimistic UI.

Can tRPC work with App Router?

Yes. Create app/api/trpc/[trpc]/route.ts. Use createTRPCReact from @trpc/react-query with httpBatchLink. Server components can use createCaller directly.

Is tRPC still relevant with Server Actions?

Yes. Server Actions lack caching, batching, subscriptions. tRPC fills those gaps. Hybrid is standard, not replacement.

How test both?

Server Actions: call function directly with FormData. tRPC: use createCaller to test procedures without HTTP.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousReact Server Components Performance Deep Dive (2026)Next β†’Building Production-Grade AI Features in Next.js 16
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged