karawaci.kode

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:

  1. Tambah REST API endpoint terpisah (duplicate logic) — saya pilih ini
  2. Tambah tRPC server terpisah (lebih banyak setup) — saya skip
  3. 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:

  1. 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.

  2. File upload limit Vercel function: 4.5MB body. Server Actions inherit limit ini. Pakai pre-signed URL pattern untuk file besar.

tRPC:

  1. 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.

  2. Error serialization: thrown TRPCError di server kadang lose stack trace di Sentry. Saya pakai custom error formatter untuk preserve.

  3. 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