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
-
Error boundary di Server Action: thrown error di action tidak otomatis surface ke client error boundary. Harus return
{ error: "..." }pattern. Saya tulis utilsafeAction()wrapper yang catch + return uniform. -
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.
-
Revalidation cascade:
revalidatePath('/invoices')invalidate semua child route. Saya pernah over-invalidate dan cache hit ratio turun dari 78% ke 32%. Sekarang saya pakairevalidateTagyang lebih surgical. -
CSRF: Server Actions automatic dapat CSRF protection via
Next-Actionheader + 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