karawaci.kode

2026-06-03 · 7 min

Next.js 15 Server Actions di SaaS Jakarta 6 Bulan

Klien SaaS invoice di Jakarta (B2B, ~800 paying tenant, MRR Rp 95jt) tahun lalu request migrasi dari Pages Router + REST API ke App Router + Server Actions. Saya commit 6 bulan parallel + migration. Hari ini status real.

Setup

App: SaaS invoice + e-faktur. Stack lama: Next.js 14 Pages Router, REST API dengan Express handler terpisah, TanStack Query di client, validasi Zod manual di kedua sisi.

Stack baru: Next.js 15 App Router, Server Actions, validasi Zod sekali di action, React 19 untuk useActionState.

Database: Postgres 17 di Hetzner cx41, sama. Auth: Auth.js v5 (dulu NextAuth). Sama. Hosting: Vercel Pro untuk klien ini (mereka mau, saya tidak protes karena bukan project saya).

LOC delta

Sebelum (Pages Router + REST):

  • API routes: 4,200 LOC
  • Client fetch logic + TanStack Query setup: 2,800 LOC
  • Schema duplication (Zod di server + client): 1,100 LOC
  • Total interface layer: 8,100 LOC

Sesudah (Server Actions):

  • Action files: 3,800 LOC
  • Client form logic dengan useActionState: 1,500 LOC
  • Zod schema (single source): 250 LOC
  • Total interface layer: 5,550 LOC

Reduction: ~32% (2,550 LOC). Bukan trivial untuk maintenance budget.

Latency

Test dengan k6, simulate user di Jakarta dengan koneksi Indihome 50Mbps:

Form submit invoice (sebelum):

  • P50: 340ms (client → API → DB → response)
  • P95: 720ms
  • P99: 1,180ms

Form submit invoice (sesudah, Server Actions):

  • P50: 295ms
  • P95: 590ms
  • P99: 980ms

P95 improvement ~18%. Source: less serialization overhead (no JSON round-trip explicit), RSC streaming partial response, dan revalidation lebih granular.

Bukan dramatic, tapi konsisten di semua endpoint.

Yang break (yang bikin customer marah)

Bulan ke-2 production, customer support dapet 14 ticket dalam 2 hari: “form invoice nge-hang kalau saya klik save dua kali cepat.”

Investigation: Server Actions secara default serial untuk form yang sama dalam React 19. Kalau user double-click, action kedua nunggu yang pertama selesai. Untuk form yang slow (e.g., generate PDF e-faktur, 2-3 detik), user feel UI freeze.

Fix:

// Sebelum: default behavior, action serial
<form action={createInvoice}>
  <button>Save</button>
</form>

// Sesudah: explicit handle dengan optimistic + dedupe
const [state, formAction, isPending] = useActionState(createInvoice, null);

<form action={formAction}>
  <button disabled={isPending}>
    {isPending ? "Memproses..." : "Save"}
  </button>
</form>

disabled={isPending} cegah double-submit. Kombinasi dengan optimistic UI via useOptimistic untuk feedback instant.

Cost insiden: 14 ticket × 25 menit support time = 5,8 jam support kerja. Plus saya 4 jam audit + fix di 23 form di app. Total damage ~10 jam.

Yang juga gotcha

  1. Error boundary di Server Action: thrown error di action tidak otomatis surface ke client error boundary. Harus return { error: "..." } pattern. Saya tulis util safeAction() wrapper yang catch + return uniform.

  2. File upload: Server Actions support FormData dengan File. Tapi multipart streaming ke external storage (R2) butuh extra setup. Saya pakai pre-signed URL pattern untuk file > 4MB.

  3. Revalidation cascade: revalidatePath('/invoices') invalidate semua child route. Saya pernah over-invalidate dan cache hit ratio turun dari 78% ke 32%. Sekarang saya pakai revalidateTag yang lebih surgical.

  4. CSRF: Server Actions automatic dapat CSRF protection via Next-Action header + origin check. Lebih aman dari REST yang dulu saya implement manual. Detail bahasan: CSRF di SPA 2026.

Memory & cost

Vercel Pro Function execution turun dari rata-rata 280ms ke 240ms per invocation. Cost Vercel turun dari $145/mo ke $118/mo. Saving ~$27/mo (~Rp 430k).

Build time naik 22% (App Router + RSC compile lebih heavy). Vercel build minutes usage naik tapi masih dalam quota Pro.

Pola yang stick

// Pattern saya untuk semua action: validate, action, revalidate, return
'use server';

export async function createInvoice(prev: State, formData: FormData) {
  const parsed = invoiceSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }
  
  try {
    await db.invoice.create({ data: parsed.data });
    revalidateTag('invoices');
    return { success: true };
  } catch (e) {
    return { error: { _form: ['Gagal simpan, coba lagi'] } };
  }
}

Konsisten di semua action. Tim saya yang 2 orang junior bisa follow pattern ini tanpa banyak hand-holding.

Verdict

Server Actions worth migration untuk app Next.js full-stack dengan banyak form. 32% LOC reduction = maintenance budget yang ke-recover. P95 latency improvement modest tapi konsisten.

Bukan untuk app yang butuh mobile native client (kasus saya: ada Flutter app yang sharing API). Untuk itu, tetap REST atau tRPC.

Bukan magic — gotcha double-submit nyata dan kena customer beneran. Worth-it overall, tapi prepare untuk learn beberapa pattern baru sebelum push ke prod.

Ditulis oleh Reza Pradipta