Server Actions di Next.js 16: Panduan Pola, Keamanan, dan Best Practice

Pelajari Server Actions di Next.js 16 secara mendalam — dari penanganan form dengan useActionState, optimistic updates, mutasi database aman dengan Data Access Layer, hingga strategi keamanan berlapis. Lengkap dengan contoh kode siap produksi.

Mengapa Server Actions Jadi Andalan di Aplikasi Next.js Modern?

Sejak pertama kali hadir secara stabil di Next.js 14 dan terus dipoles hingga Next.js 16, Server Actions benar-benar mengubah cara kita membangun aplikasi full-stack dengan React. Dulu, kita harus bikin API route terpisah untuk setiap operasi mutasi data — sekarang? Server Actions memungkinkan kita menulis fungsi server langsung di dalam (atau berdampingan dengan) komponen React.

Tapi jangan salah, kemudahan ini datang dengan tanggung jawab yang nggak bisa disepelekan.

Setiap fungsi yang ditandai dengan "use server" secara otomatis menjadi endpoint HTTP POST publik. Tanpa validasi, autentikasi, dan otorisasi yang tepat di setiap action, aplikasi Anda terbuka lebar terhadap berbagai serangan. Pada Desember 2025, komunitas keamanan menemukan kerentanan kritis (CVE-2025-55182 dan CVE-2025-66478) yang berkaitan langsung dengan cara Server Components dan Server Actions menangani deserialisasi data — pengingat keras bahwa keamanan harus jadi prioritas nomor satu.

Nah, dalam panduan ini kita akan kupas tuntas Server Actions dari dasar hingga pola-pola lanjutan: penanganan form dengan React 19 hooks, mutasi database yang aman, optimistic updates, sampai strategi keamanan berlapis yang siap produksi. Semua contoh menggunakan Next.js 16 dan React 19.

Memahami Fundamental Server Actions

Apa Sebenarnya Server Actions Itu?

Sederhananya, Server Actions adalah fungsi asinkron yang dieksekusi di server. Mereka ditandai dengan direktif "use server" dan bisa dipanggil dari Server Components maupun Client Components. Yang bikin Server Actions beda dari API route biasa adalah integrasinya yang mulus dengan sistem rendering dan caching Next.js.

// app/actions/user.ts
"use server";

export async function updateProfile(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  // Validasi, autentikasi, lalu mutasi database
  await db.users.update({
    where: { id: getCurrentUserId() },
    data: { name, email },
  });

  revalidatePath("/profile");
}

Ketika Anda menandai sebuah file dengan "use server" di bagian atas, semua fungsi yang diekspor dari file tersebut otomatis jadi Server Actions. Ini pendekatan yang direkomendasikan karena memisahkan logika server dari komponen UI secara bersih.

Dua Cara Mendefinisikan Server Actions

Ada dua pendekatan utama, dan masing-masing punya use case-nya sendiri.

1. File Terpisah (Direkomendasikan) — Semua actions dalam satu file dedicated:

// app/actions/posts.ts
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await db.posts.create({ data: { title, content } });
  revalidatePath("/posts");
  redirect("/posts");
}

export async function deletePost(postId: string) {
  await db.posts.delete({ where: { id: postId } });
  revalidatePath("/posts");
}

2. Inline di Server Component — Untuk action sederhana yang cuma dipakai di satu tempat:

// app/posts/page.tsx (Server Component)
export default function PostsPage() {
  async function handleLike(formData: FormData) {
    "use server";
    const postId = formData.get("postId") as string;
    await db.posts.incrementLike(postId);
    revalidatePath("/posts");
  }

  return (
    <form action={handleLike}>
      <input type="hidden" name="postId" value="123" />
      <button type="submit">Suka</button>
    </form>
  );
}

Secara pribadi, saya lebih suka pendekatan file terpisah. Lebih rapi, lebih mudah di-maintain, dan action-nya bisa di-reuse di berbagai komponen tanpa duplikasi kode.

Bagaimana Server Actions Bekerja di Balik Layar

Memahami mekanisme internal ini penting banget buat debugging dan keamanan. Begini prosesnya:

  1. Next.js membuat endpoint HTTP POST unik untuk setiap Server Action saat build time
  2. Ketika form disubmit, browser mengirim POST request dengan FormData ke endpoint tersebut
  3. Server menjalankan fungsi action dan mengembalikan respons
  4. Next.js secara otomatis memperbarui UI berdasarkan hasil action

Karena menggunakan metode POST dan membandingkan header Origin dengan Host, Server Actions punya perlindungan CSRF bawaan. Tapi — dan ini penting — bukan berarti Anda bisa santai soal keamanan. Proteksi CSRF hanyalah satu lapisan dari banyak lapisan keamanan yang diperlukan.

Penanganan Form dengan React 19 Hooks

useActionState: Manajemen State Form yang Lebih Elegan

React 19 memperkenalkan hook useActionState yang menggantikan useFormState dari versi sebelumnya. Jujur, ini salah satu improvement favorit saya karena menyediakan cara deklaratif untuk mengelola state form — termasuk status pending, error, dan data hasil — dalam satu hook yang ringkas.

// app/actions/contact.ts
"use server";

import { z } from "zod";

const ContactSchema = z.object({
  name: z.string().min(2, "Nama minimal 2 karakter"),
  email: z.string().email("Format email tidak valid"),
  message: z.string().min(10, "Pesan minimal 10 karakter"),
});

export type ContactState = {
  errors?: {
    name?: string[];
    email?: string[];
    message?: string[];
  };
  message?: string;
  success?: boolean;
};

export async function submitContact(
  prevState: ContactState,
  formData: FormData
): Promise<ContactState> {
  const validated = ContactSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  });

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors,
      message: "Harap perbaiki kesalahan pada form.",
    };
  }

  try {
    await db.contacts.create({ data: validated.data });
    return { success: true, message: "Pesan berhasil dikirim!" };
  } catch (error) {
    return { message: "Terjadi kesalahan. Silakan coba lagi." };
  }
}
// app/contact/contact-form.tsx
"use client";

import { useActionState } from "react";
import { submitContact, type ContactState } from "@/app/actions/contact";

const initialState: ContactState = {};

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(
    submitContact,
    initialState
  );

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="name">Nama</label>
        <input id="name" name="name" type="text" required />
        {state.errors?.name && (
          <p className="text-red-500">{state.errors.name[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
        {state.errors?.email && (
          <p className="text-red-500">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="message">Pesan</label>
        <textarea id="message" name="message" rows={4} required />
        {state.errors?.message && (
          <p className="text-red-500">{state.errors.message[0]}</p>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "Mengirim..." : "Kirim Pesan"}
      </button>

      {state.message && (
        <p className={state.success ? "text-green-600" : "text-red-600"}>
          {state.message}
        </p>
      )}
    </form>
  );
}

Perhatikan bahwa useActionState mengembalikan tiga nilai: state (hasil terakhir dari action), formAction (fungsi yang digunakan sebagai action form), dan isPending (status apakah action sedang berjalan). Jauh lebih bersih dibandingkan mengelola state loading secara manual, kan?

useFormStatus: Indikator Loading di Komponen Anak

Hook useFormStatus dari react-dom memungkinkan komponen anak di dalam form untuk mengakses status pending form induknya. Ini berguna banget untuk membuat tombol submit yang reusable:

// components/submit-button.tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton({
  children,
  loadingText = "Memproses...",
}: {
  children: React.ReactNode;
  loadingText?: string;
}) {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? loadingText : children}
    </button>
  );
}

Satu hal yang perlu diingat: useFormStatus harus digunakan di dalam komponen yang merupakan anak dari elemen <form>. Hook ini tidak akan berfungsi kalau ditempatkan di komponen yang sama dengan form itu sendiri. Ini salah satu gotcha yang sering bikin bingung developer baru.

Progressive Enhancement: Form yang Tetap Jalan Tanpa JavaScript

Salah satu keunggulan besar Server Actions dibandingkan pendekatan tradisional berbasis fetch adalah dukungan progressive enhancement. Ketika Anda menggunakan prop action pada form HTML, form tersebut tetap berfungsi meskipun JavaScript gagal dimuat atau belum selesai di-hydrate:

// Form ini berfungsi bahkan sebelum JavaScript dimuat
<form action={createPost}>
  <input name="title" type="text" required />
  <textarea name="content" required />
  <button type="submit">Buat Artikel</button>
</form>

Artinya, pengguna dengan koneksi lambat nggak perlu menunggu bundle JavaScript selesai diunduh sebelum bisa berinteraksi dengan form. Pengalaman yang inklusif dan aksesibel secara default — hal yang sering kita abaikan tapi sebenarnya sangat penting.

Mutasi Database yang Aman dan Terstruktur

Arsitektur Data Access Layer

Best practice yang direkomendasikan oleh tim Next.js (dan menurut saya memang seharusnya jadi standar) adalah memisahkan logika akses data ke dalam Data Access Layer (DAL) yang terisolasi. Setiap fungsi di DAL bertanggung jawab memverifikasi autentikasi dan otorisasi sebelum mengeksekusi query apapun.

// lib/dal/posts.ts
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { cache } from "react";

// Gunakan React cache untuk deduplikasi
export const getCurrentUser = cache(async () => {
  const session = await auth();
  if (!session?.user?.id) {
    throw new Error("Tidak terautentikasi");
  }
  return session.user;
});

export async function getUserPosts() {
  const user = await getCurrentUser();
  return db.posts.findMany({
    where: { authorId: user.id },
    orderBy: { createdAt: "desc" },
  });
}

export async function createPost(data: {
  title: string;
  content: string;
}) {
  const user = await getCurrentUser();
  return db.posts.create({
    data: {
      ...data,
      authorId: user.id,
    },
  });
}

export async function deletePost(postId: string) {
  const user = await getCurrentUser();

  // Verifikasi kepemilikan (otorisasi)
  const post = await db.posts.findUnique({
    where: { id: postId },
  });

  if (!post || post.authorId !== user.id) {
    throw new Error("Tidak memiliki akses untuk menghapus artikel ini");
  }

  return db.posts.delete({ where: { id: postId } });
}

Server Actions dengan Validasi Zod

Ini bagian yang krusial. Setiap Server Action harus memvalidasi input menggunakan library seperti Zod. Kenapa? Karena tipe TypeScript menghilang saat runtime. Seseorang bisa dengan mudah mengirim payload apa pun ke endpoint Server Action Anda menggunakan cURL atau Postman, jadi validasi runtime bukan opsional — ini wajib.

// app/actions/posts.ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createPost as dalCreatePost } from "@/lib/dal/posts";

const CreatePostSchema = z.object({
  title: z
    .string()
    .min(3, "Judul minimal 3 karakter")
    .max(200, "Judul maksimal 200 karakter"),
  content: z
    .string()
    .min(50, "Konten minimal 50 karakter")
    .max(50000, "Konten terlalu panjang"),
});

export type CreatePostState = {
  errors?: Record<string, string[]>;
  message?: string;
};

export async function createPost(
  prevState: CreatePostState,
  formData: FormData
): Promise<CreatePostState> {
  // 1. Validasi input
  const validated = CreatePostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors,
      message: "Validasi gagal",
    };
  }

  // 2. DAL menangani autentikasi + otorisasi + mutasi
  try {
    await dalCreatePost(validated.data);
  } catch (error) {
    if (error instanceof Error && error.message === "Tidak terautentikasi") {
      redirect("/login");
    }
    return { message: "Gagal membuat artikel. Silakan coba lagi." };
  }

  // 3. Revalidasi cache dan redirect
  revalidatePath("/posts");
  redirect("/posts");
}

Pola di atas menunjukkan defense-in-depth yang benar: validasi input di Server Action, autentikasi dan otorisasi di Data Access Layer. Dengan begitu, bahkan jika seseorang memanggil DAL dari tempat lain, keamanan tetap terjaga.

Menggunakan next-safe-action untuk Boilerplate yang Lebih Ringkas

Kalau Anda merasa pola validasi-autentikasi-otorisasi di atas terlalu bertele-tele, library next-safe-action bisa menyederhanakan semuanya jadi middleware yang composable:

// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
import { auth } from "@/lib/auth";

// Client dasar — untuk action publik
export const actionClient = createSafeActionClient();

// Client terautentikasi — memverifikasi sesi otomatis
export const authenticatedAction = createSafeActionClient({
  async middleware() {
    const session = await auth();
    if (!session?.user) {
      throw new Error("Tidak terautentikasi");
    }
    return { user: session.user };
  },
});
// app/actions/posts.ts
"use server";

import { z } from "zod";
import { authenticatedAction } from "@/lib/safe-action";
import { revalidatePath } from "next/cache";

export const createPost = authenticatedAction
  .schema(
    z.object({
      title: z.string().min(3).max(200),
      content: z.string().min(50).max(50000),
    })
  )
  .action(async ({ parsedInput, ctx }) => {
    // ctx.user sudah terverifikasi oleh middleware
    await db.posts.create({
      data: {
        ...parsedInput,
        authorId: ctx.user.id,
      },
    });

    revalidatePath("/posts");
    return { success: true };
  });

Dengan next-safe-action, validasi Zod dan verifikasi autentikasi ditangani secara deklaratif. Hasilnya? Kode yang lebih ringkas dan konsistensi yang terjaga di seluruh action Anda. Menurut saya, ini pendekatan yang worth it terutama untuk proyek berskala menengah ke atas.

Optimistic Updates: UI yang Terasa Instan

useOptimistic untuk Pengalaman Real-Time

Optimistic updates itu konsepnya sederhana tapi efeknya luar biasa: UI diperbarui sebelum server merespons, dengan asumsi operasi akan berhasil. Kalau ternyata gagal, UI dikembalikan ke state sebelumnya. React 19 menyediakan hook useOptimistic untuk implementasi pola ini.

// app/posts/post-list.tsx
"use client";

import { useOptimistic } from "react";
import { toggleLike } from "@/app/actions/posts";

type Post = {
  id: string;
  title: string;
  likes: number;
  isLiked: boolean;
};

export function PostList({ posts }: { posts: Post[] }) {
  const [optimisticPosts, setOptimisticPost] = useOptimistic(
    posts,
    (currentPosts, updatedPostId: string) =>
      currentPosts.map((post) =>
        post.id === updatedPostId
          ? {
              ...post,
              likes: post.isLiked ? post.likes - 1 : post.likes + 1,
              isLiked: !post.isLiked,
            }
          : post
      )
  );

  async function handleLike(postId: string) {
    setOptimisticPost(postId); // UI langsung berubah
    await toggleLike(postId); // Server memproses di background
  }

  return (
    <ul>
      {optimisticPosts.map((post) => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <button onClick={() => handleLike(post.id)}>
            {post.isLiked ? "❤️" : "🤍"} {post.likes}
          </button>
        </li>
      ))}
    </ul>
  );
}

Ketika pengguna mengklik tombol like, UI langsung berubah tanpa menunggu respons server. Kalau server action gagal, React secara otomatis mengembalikan state ke nilai aslinya. Hasilnya? Pengalaman yang terasa instan, bahkan di koneksi yang lemot sekalipun.

Optimistic Updates untuk Operasi CRUD

Pola ini bisa diterapkan pada operasi yang lebih kompleks juga, seperti menambah item ke daftar. Berikut contoh todo list dengan optimistic add:

// app/todos/todo-list.tsx
"use client";

import { useOptimistic, useRef } from "react";
import { addTodo } from "@/app/actions/todos";

type Todo = {
  id: string;
  text: string;
  completed: boolean;
};

export function TodoList({ todos }: { todos: Todo[] }) {
  const formRef = useRef<HTMLFormElement>(null);
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, newTodoText: string) => [
      ...currentTodos,
      {
        id: `temp-${Date.now()}`,
        text: newTodoText,
        completed: false,
      },
    ]
  );

  async function handleSubmit(formData: FormData) {
    const text = formData.get("text") as string;
    if (!text.trim()) return;

    formRef.current?.reset();
    addOptimisticTodo(text);
    await addTodo(formData);
  }

  return (
    <div>
      <form ref={formRef} action={handleSubmit}>
        <input name="text" placeholder="Tambah tugas baru..." required />
        <button type="submit">Tambah</button>
      </form>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id} style={{
            opacity: todo.id.startsWith("temp-") ? 0.6 : 1
          }}>
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

Perhatikan trik ID sementara (temp-) untuk item optimistic — ini memungkinkan kita kasih indikator visual (opacity lebih rendah) bahwa item tersebut masih diproses. Setelah server merespons dan revalidatePath dipanggil, data asli dari server akan menggantikan state optimistic secara otomatis. Cukup elegan, bukan?

Keamanan Server Actions: Defense-in-Depth

Ancaman Keamanan yang Wajib Anda Pahami

Oke, bagian ini serius. Sebelum kita bahas solusinya, mari pahami dulu ancaman utama yang mengintai Server Actions:

  • Injection Attack — Input pengguna yang tidak divalidasi bisa disuntikkan ke query database. Meskipun ORM seperti Prisma dan Drizzle punya parameterized queries, tetap validasi input sebagai lapisan pertahanan tambahan.
  • Unauthorized Access — Tanpa pengecekan autentikasi di setiap action, siapa pun bisa memanggil endpoint action secara langsung pakai cURL atau tools lainnya.
  • Privilege Escalation — Pengguna bisa memodifikasi data milik pengguna lain jika otorisasi (kepemilikan resource) tidak diverifikasi.
  • Information Disclosure — CVE-2025-66478 menunjukkan bahwa permintaan HTTP yang dibuat secara khusus bisa mengekspos kode sumber Server Actions lainnya, termasuk rahasia yang di-hardcode. Ngeri, kan?
  • Denial of Service — Tanpa rate limiting, penyerang bisa membanjiri server action Anda dengan ribuan permintaan sekaligus.

Checklist Keamanan untuk Setiap Server Action

Setiap Server Action yang Anda tulis harus melewati beberapa tahap verifikasi. Saya biasanya mengikuti pola ini sebagai template:

// Template keamanan Server Action
"use server";

import { z } from "zod";
import { auth } from "@/lib/auth";
import { rateLimit } from "@/lib/rate-limit";

const InputSchema = z.object({
  // Definisikan skema validasi yang ketat
});

export async function secureAction(formData: FormData) {
  // TAHAP 1: Rate Limiting
  const ip = headers().get("x-forwarded-for") ?? "unknown";
  const { success } = await rateLimit.limit(ip);
  if (!success) {
    throw new Error("Terlalu banyak permintaan. Coba lagi nanti.");
  }

  // TAHAP 2: Autentikasi
  const session = await auth();
  if (!session?.user) {
    throw new Error("Anda harus login untuk melakukan aksi ini.");
  }

  // TAHAP 3: Validasi Input
  const validated = InputSchema.safeParse({
    // parse formData
  });
  if (!validated.success) {
    return { error: "Data tidak valid" };
  }

  // TAHAP 4: Otorisasi (jika ada resource yang diakses)
  const resource = await db.resource.findUnique({
    where: { id: validated.data.resourceId },
  });
  if (resource?.ownerId !== session.user.id) {
    throw new Error("Akses ditolak.");
  }

  // TAHAP 5: Eksekusi operasi
  // ... lakukan mutasi database
}

Implementasi Rate Limiting

Rate limiting adalah garis pertahanan pertama terhadap penyalahgunaan. Untuk lingkungan serverless, Upstash Redis jadi pilihan yang solid:

// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

// Batasi 10 request per 10 detik per IP
export const rateLimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "10 s"),
  analytics: true,
  prefix: "server-action",
});

Nggak pakai Redis? Tenang, Anda bisa bikin rate limiter sederhana berbasis memori. Cocok untuk single-instance deployment:

// lib/rate-limit-memory.ts
const rateLimitMap = new Map<
  string,
  { count: number; lastReset: number }
>();

export function checkRateLimit(
  key: string,
  limit: number = 10,
  windowMs: number = 60000
): boolean {
  const now = Date.now();
  const entry = rateLimitMap.get(key);

  if (!entry || now - entry.lastReset > windowMs) {
    rateLimitMap.set(key, { count: 1, lastReset: now });
    return true;
  }

  if (entry.count >= limit) {
    return false;
  }

  entry.count++;
  return true;
}

Jangan Pernah Hardcode Rahasia di Server Actions

Ini kelihatannya sepele, tapi CVE-2025-66478 membuktikan bahwa ini risiko nyata. Selalu gunakan environment variables:

// ❌ JANGAN LAKUKAN INI
"use server";

const API_KEY = "sk-secret-key-12345"; // Bisa terekspos!

export async function callExternalApi() {
  const res = await fetch("https://api.example.com", {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  // ...
}

// ✅ LAKUKAN INI
"use server";

export async function callExternalApi() {
  const apiKey = process.env.EXTERNAL_API_KEY; // Aman!
  const res = await fetch("https://api.example.com", {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  // ...
}

Pastikan Versi Next.js Anda Selalu Terupdate

Kerentanan kritis yang ditemukan pada akhir 2025 (CVE-2025-55182 untuk React dan CVE-2025-66478 untuk Next.js) sudah diperbaiki di versi terbaru. Pastikan Anda menggunakan Next.js versi 16.0.4 atau lebih baru, dan React versi 19.0.0-rc-12-patched atau lebih baru.

Biasakan jalankan npm audit secara rutin — butuh waktu kurang dari satu menit dan bisa menyelamatkan Anda dari masalah besar.

Pola-Pola Lanjutan Server Actions

Server Actions dengan Bind untuk Argumen Tambahan

Kadang Anda perlu mengirim argumen tambahan ke Server Action selain FormData. Di sinilah .bind() berguna:

// app/actions/cart.ts
"use server";

export async function addToCart(productId: string, formData: FormData) {
  const quantity = Number(formData.get("quantity")) || 1;
  // productId sudah tersedia dari bind
  await db.cart.upsert({
    where: { productId },
    update: { quantity: { increment: quantity } },
    create: { productId, quantity },
  });
  revalidatePath("/cart");
}
// app/products/[id]/page.tsx
import { addToCart } from "@/app/actions/cart";

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  const addToCartWithId = addToCart.bind(null, params.id);

  return (
    <form action={addToCartWithId}>
      <input name="quantity" type="number" defaultValue={1} min={1} />
      <button type="submit">Tambah ke Keranjang</button>
    </form>
  );
}

Pola .bind() ini terasa alami dan tetap menjaga type safety. Argumen yang di-bind akan dikirim sebagai data tersembunyi bersama form submission.

Server Actions di Luar Form (Event Handler)

Server Actions nggak terbatas pada form saja. Anda bisa memanggilnya dari event handler apa pun di Client Component, menggunakan useTransition untuk mengelola state loading:

// app/notifications/notification-bell.tsx
"use client";

import { useTransition } from "react";
import { markAllAsRead } from "@/app/actions/notifications";

export function NotificationBell({ unreadCount }: { unreadCount: number }) {
  const [isPending, startTransition] = useTransition();

  function handleMarkAllRead() {
    startTransition(async () => {
      await markAllAsRead();
    });
  }

  return (
    <div>
      <span>🔔 {unreadCount}</span>
      {unreadCount > 0 && (
        <button onClick={handleMarkAllRead} disabled={isPending}>
          {isPending ? "Memproses..." : "Tandai semua dibaca"}
        </button>
      )}
    </div>
  );
}

Revalidasi dan Redirect yang Benar

Memahami kapan menggunakan revalidatePath, revalidateTag, dan redirect itu penting. Ini salah satu area yang sering jadi sumber bug kalau nggak hati-hati:

"use server";

import { revalidatePath } from "next/cache";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";

// Revalidasi path — untuk halaman spesifik
export async function updatePost(id: string, formData: FormData) {
  await db.posts.update({ where: { id }, data: { /* ... */ } });
  revalidatePath(`/posts/${id}`); // Halaman detail
  revalidatePath("/posts");        // Halaman daftar
}

// Revalidasi tag — untuk data yang di-cache dengan tag
export async function refreshProducts() {
  await db.products.syncFromInventory();
  revalidateTag("products"); // Semua cache dengan tag "products"
}

// Redirect setelah mutasi — HARUS dipanggil di luar try/catch
export async function deletePost(id: string) {
  try {
    await db.posts.delete({ where: { id } });
  } catch (error) {
    return { error: "Gagal menghapus artikel" };
  }

  revalidatePath("/posts");
  redirect("/posts"); // Di luar try/catch!
}

Ini penting banget: fungsi redirect() secara internal melempar error khusus yang ditangkap oleh Next.js. Jadi, redirect harus dipanggil di luar blok try/catch. Kalau tidak, error-nya akan tertangkap duluan dan redirect nggak akan jalan. Banyak developer yang kena masalah ini, jadi ingat baik-baik.

Error Handling yang Proper

Server Actions yang siap produksi harus menangani error secara graceful. Jangan cuma kasih pesan "Something went wrong" — berikan informasi yang actionable kepada pengguna:

"use server";

import { z } from "zod";

type ActionResult<T = void> =
  | { success: true; data: T }
  | { success: false; error: string; fieldErrors?: Record<string, string[]> };

export async function registerUser(
  formData: FormData
): Promise<ActionResult<{ userId: string }>> {
  // Validasi
  const parsed = RegisterSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
    name: formData.get("name"),
  });

  if (!parsed.success) {
    return {
      success: false,
      error: "Data tidak valid",
      fieldErrors: parsed.error.flatten().fieldErrors,
    };
  }

  try {
    // Cek duplikasi email
    const existing = await db.users.findUnique({
      where: { email: parsed.data.email },
    });

    if (existing) {
      return {
        success: false,
        error: "Email sudah terdaftar",
        fieldErrors: { email: ["Email ini sudah digunakan"] },
      };
    }

    // Buat pengguna baru
    const user = await db.users.create({
      data: {
        ...parsed.data,
        password: await hashPassword(parsed.data.password),
      },
    });

    return { success: true, data: { userId: user.id } };
  } catch (error) {
    console.error("Gagal mendaftarkan pengguna:", error);
    return {
      success: false,
      error: "Terjadi kesalahan server. Silakan coba lagi.",
    };
  }
}

Integrasi dengan Next.js 16: Cache Components dan Server Actions

Menggabungkan "use cache" dengan Server Actions

So, bagaimana Server Actions bekerja bersama fitur Cache Components baru di Next.js 16? Cukup harmonis, ternyata. Ketika Server Action memanggil revalidateTag atau revalidatePath, komponen yang ditandai dengan "use cache" akan otomatis di-revalidasi:

// app/dashboard/stats.tsx
async function DashboardStats() {
  "use cache";

  const stats = await db.analytics.getOverview();
  return (
    <div>
      <p>Total Pengguna: {stats.totalUsers}</p>
      <p>Artikel Bulan Ini: {stats.monthlyPosts}</p>
      <p>Pendapatan: Rp{stats.revenue.toLocaleString("id-ID")}</p>
    </div>
  );
}
// app/actions/admin.ts
"use server";

import { revalidatePath } from "next/cache";

export async function approveUser(userId: string) {
  await db.users.update({
    where: { id: userId },
    data: { isApproved: true },
  });

  // Ini akan menyegarkan komponen DashboardStats
  // yang menggunakan "use cache"
  revalidatePath("/dashboard");
}

Model mentalnya sederhana: "use cache" untuk menandai apa yang bisa di-cache, Server Actions untuk memutasi data dan memicu revalidasi. Dua fitur ini saling melengkapi — yang satu bikin aplikasi cepat, yang lain menjaga data tetap fresh.

Kapan Pakai Server Actions vs API Route

Meskipun Server Actions sangat powerful, ada situasi di mana API Routes tetap lebih tepat. Berikut panduan sederhananya:

  • Gunakan Server Actions untuk: mutasi data yang dipicu oleh interaksi pengguna (form submit, klik tombol), operasi CRUD, server-side validation, dan aksi yang memerlukan revalidasi cache.
  • Gunakan API Routes untuk: webhook dari layanan eksternal, integrasi dengan aplikasi pihak ketiga, endpoint yang diakses oleh mobile app atau sistem lain, dan operasi long-polling atau streaming yang tidak terkait UI.

Satu hal yang sering dilupakan: Server Actions dirancang untuk mutasi, bukan fetching data. Untuk membaca data, gunakan Server Components dengan async/await secara langsung — lebih efisien dan nggak perlu endpoint tambahan.

Ringkasan dan Praktik Terbaik

Server Actions sudah matang jadi fitur fundamental di ekosistem Next.js. Setelah membahas panjang lebar, berikut rangkuman praktik terbaik yang sebaiknya Anda terapkan:

  1. Pisahkan actions ke file dedicated — Gunakan "use server" di level file, bukan inline, untuk organisasi yang lebih baik dan reusability.
  2. Selalu validasi input di runtime — Gunakan Zod atau library validasi lainnya. Tipe TypeScript nggak cukup karena menghilang saat runtime.
  3. Terapkan autentikasi dan otorisasi di setiap action — Atau lebih baik lagi, di Data Access Layer. Jangan pernah cuma mengandalkan middleware.
  4. Implementasikan rate limiting — Terutama untuk action sensitif seperti login, registrasi, atau pengiriman email.
  5. Gunakan useActionState dan useFormStatus — Manfaatkan React 19 hooks untuk penanganan form yang lebih deklaratif.
  6. Terapkan optimistic updates — Gunakan useOptimistic untuk UX yang terasa instan.
  7. Jangan hardcode rahasia — Selalu gunakan environment variables. CVE-2025-66478 jadi bukti nyata risikonya.
  8. Update dependensi secara berkala — Jalankan npm audit secara rutin dan perbarui Next.js serta React ke versi terbaru.
  9. Panggil redirect() di luar try/catch — Karena redirect melempar error internal Next.js yang akan tertangkap oleh catch block.
  10. Manfaatkan progressive enhancement — Gunakan prop action pada form agar aplikasi tetap berfungsi sebelum JavaScript dimuat.

Dengan menguasai pola-pola di atas, Anda bisa membangun aplikasi Next.js full-stack yang aman, performant, dan memberikan pengalaman pengguna yang menyenangkan. Server Actions bukan sekadar pengganti API route — mereka adalah cara baru untuk membangun interaksi server-client yang mulus di era React Server Components.

Tentang Penulis Editorial Team

Our team of expert writers and editors.