2026-06-15 · 7 min
Server Actions vs tRPC di SaaS 2026: Mana yang Lebih Cepat
Saya operate 2 SaaS klien Jakarta dengan struktur mirip: subscription B2B, ~600-800 tenant, dashboard web + mobile native (Flutter) untuk team mereka. Project A pakai Server Actions full (yang saya tulis kemarin). Project B saya tetap pakai tRPC. 90 hari side-by-side. Hasil.
Setup
Project A: Next.js 15 App Router, Server Actions, web-only (mobile akan ditambah di phase 2).
Project B: Next.js 15 App Router + Express tRPC server, Flutter client share schema lewat code generation.
Database keduanya: Postgres 17 di Hetzner cx41. Sama region (Singapore).
Latency
Saya benchmark 5 typical operation: list, get-by-id, create, update, delete.
Project A (Server Actions) dari Jakarta Indihome:
- List 50 invoices: P50 220ms, P95 480ms
- Get by ID: P50 95ms, P95 220ms
- Create: P50 280ms, P95 580ms
- Update: P50 260ms, P95 540ms
- Delete: P50 240ms, P95 500ms
Project B (tRPC + Express):
- List 50 invoices: P50 195ms, P95 410ms
- Get by ID: P50 85ms, P95 195ms
- Create: P50 245ms, P95 510ms
- Update: P50 230ms, P95 475ms
- Delete: P50 215ms, P95 460ms
tRPC sekitar 10-12% lebih cepat di semua operation. Source:
- No Next-Action serialization overhead (Server Actions wrap di FormData / RSC payload)
- Express runtime lebih lean dari Next.js full request handler
- HTTP/2 native (Next.js 15 Vercel pakai HTTP/2, tapi route handler tetap punya overhead RSC)
10-12% bukan dramatic. Untuk web user: tidak feel. Tapi untuk mobile dengan koneksi mediocre, akumulate.
DX (developer experience)
Server Actions:
// Action
'use server';
export async function createInvoice(prev, formData) {
const parsed = invoiceSchema.parse(Object.fromEntries(formData));
return await db.insert(invoices).values(parsed);
}
// Component
<form action={createInvoice}>
<input name="amount" />
</form>
Sangat clean untuk web form. Progressive enhancement bonus. Type-safe via TS inference.
tRPC:
// Router (server)
export const invoiceRouter = router({
create: protectedProcedure
.input(invoiceSchema)
.mutation(async ({ input, ctx }) => {
return await ctx.db.insert(invoices).values(input);
}),
});
// Component (client)
const createInvoice = trpc.invoice.create.useMutation();
createInvoice.mutate({ amount: 100000 });
Sedikit lebih banyak boilerplate. Tapi:
- Mobile (Flutter) bisa consume via OpenAPI schema generation
- Partner integration via REST adapter
- Caching pattern (React Query) built-in
- Subscription / WebSocket support
LOC delta untuk feature serupa: Server Actions ~30% lebih sedikit LOC di web. tRPC lebih banyak tapi reusable lintas client.
Mobile compatibility
Project B (Flutter mobile) sharing schema dengan tRPC server via JSON Schema export + code generation ke Dart class. Schema sync otomatis di CI.
Project A (web-only) — saat phase 2 saya tambah Flutter mobile, opsi:
- Tambah REST API endpoint terpisah (duplicate logic) — saya pilih ini
- Tambah tRPC server terpisah (lebih banyak setup) — saya skip
- Pakai Server Actions dari Flutter (technically possible via fetch + form data, tapi awkward) — saya skip
Saya end up duplicate ~30 endpoint untuk mobile. ~2 minggu kerja extra. Kalau dari awal tau ada mobile, saya akan langsung pakai tRPC.
Memory
Project A Vercel function:
- Cold start: ~1,0s (after Drizzle migration)
- Memory rata-rata: 95MB
- Memory peak: 180MB
Project B tRPC Express di Hetzner:
- No cold start (long-running)
- Memory: 340MB rata-rata, peak 620MB
- Hosting cost: $25/mo Hetzner cx31
Project A hosting Vercel Pro: ~$120/mo.
Cost beda: Project A $95/mo lebih mahal untuk hosting. Tapi Project A ngga butuh maintain VPS (saya yang handle), savings dari ops time.
Caching pattern
Server Actions: revalidatePath / revalidateTag. Granular tapi conceptually berbeda dari TanStack Query.
revalidateTag('invoices');
// Atau lebih spesifik
revalidateTag(`invoice-${tenantId}`);
tRPC: pakai TanStack Query underneath. Familiar pattern untuk team yang sudah biasa React Query.
const utils = trpc.useUtils();
await createInvoice.mutateAsync(input);
utils.invoice.list.invalidate();
Saya prefer Server Actions tag-based untuk simple invalidation. Untuk kompleks (optimistic update + rollback): tRPC + React Query lebih established pattern.
Yang break
Server Actions:
-
CSRF protection automatic, tapi pernah ada issue saat ada user pakai browser extension yang strip Origin header. 3 user report. Fix: tambah explicit check di middleware, surface error clear.
-
File upload limit Vercel function: 4.5MB body. Server Actions inherit limit ini. Pakai pre-signed URL pattern untuk file besar.
tRPC:
-
Subscriptio via WebSocket: support tRPC v11, tapi setup di production-grade reverse proxy (nginx + WS upgrade) butuh extra config. 1 hari kerja untuk get right.
-
Error serialization: thrown
TRPCErrordi server kadang lose stack trace di Sentry. Saya pakai custom error formatter untuk preserve. -
Schema reuse Zod: tRPC + Zod sync version sometimes broken. Patch upgrade harus hati-hati.
Pola hybrid (kapan saya pakai)
Untuk project klien baru yang saya tau akan ada mobile native dalam < 12 bulan:
src/
├── lib/services/ # Pure business logic (no transport)
├── actions/ # Server Actions (web internal form)
├── server/trpc/ # tRPC router (untuk mobile + partner API)
└── pages/api/webhook/ # REST endpoint (webhook receiver)
Service layer share. Action dan tRPC procedure cuma transport wrapper.
// lib/services/invoice.ts
export async function createInvoiceService(input, ctx) {
// ... logic
}
// actions/invoice.ts
'use server';
export async function createInvoice(prev, formData) {
const ctx = await getServerContext();
return await createInvoiceService(parse(formData), ctx);
}
// server/trpc/invoice.ts
create: protectedProcedure.input(schema).mutation(({ input, ctx }) =>
createInvoiceService(input, ctx)
),
Logic ditulis sekali. Transport ditulis di tempat yang appropriate.
Verdict
- Web-only Next.js SaaS: Server Actions. Less boilerplate, faster ship.
- Multi-client (web + mobile + partner): tRPC, atau hybrid pattern dengan shared service.
- Public API: REST endpoint (Server Functions / API route). Bukan Action atau tRPC.
Bukan magic — both works. Pilihan based on client surface area, bukan hype. Untuk konsisten pattern di team kecil: pilih satu primary, secondary untuk edge case.
Saya pakai Server Actions sebagai default untuk project web-only baru. Tetap pakai tRPC untuk legacy Project B yang sudah punya Flutter client.
Ditulis oleh Reza Pradipta