Route Handlers vs Server Actions: Bedanya Apa, dan Kapan Dipakai?

✍️ Pembuka: Bingung Harus Pakai yang Mana?

“Pernah bingung harus pakai app/api atau langsung server action di komponen? Sama.”

Kamu tidak sendirian. Sejak Next.js versi 14 ke atas memperkenalkan Server Actions, banyak developer jadi bertanya-tanya: "Lho, bukannya kita sudah punya Route Handlers untuk handle request di server? Sekarang harus pilih yang mana?"

Memilih antara Route Handlers dan Server Actions bukan cuma soal gaya coding, tapi menyangkut arsitektur aplikasi, efisiensi performa, dan kebersihan kode. Salah pilih pendekatan bisa bikin kode lebih rumit, maintenance lebih sulit, dan bahkan potensi bug yang tak perlu.

Di Next.js 14+, ada dua pendekatan powerful unntuk meng-handle request:

  • Route Handlers: fleksibel, familiar, cocok untuk API konvensional.
  • Server Actions: lebih simpel, langsung dari komponen, cocok untuk form dan interaksi internal.

Tapi pertanyaannya: bedanya apa? Dan kapan harus pakai yang mana?

Yuk, kita bahas dengan bahasa santai tapi tetap tajam biar kamu bisa ambil keputusan yang tepat di proyekmu berikutnya.


🔍 Apa Itu Route Handlers?

Kalau kamu pernah pakai API Routes di Next.js versi lama (pages/api/*), maka Route Handlers adalah versi barunya — lebih modern, modular, dan terintegrasi langsung dengan struktur folder app/.

Dengan Route Handlers, kamu bisa membuat endpoint API seperti biasa (GET, POST, PUT, DELETE, dll.) langsung di folder app/api. Misalnya:

📁 app/api/products/route.ts

import { urlAllProduct, urlProductByCategory } from "@/lib/endpoint";
import { type NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const kategori = searchParams.get("kategori");
  const url = kategori ? urlProductByCategory(kategori) : urlAllProduct;
  const data = await fetch(url, { cache: "no-store" });
  return NextResponse.json(await data.json());
}
Route Handler untuk produk
Route Handler untuk produk

🛠️ Kapan Harus Dipakai?

Gunakan Route Handlers ketika:

  • Kamu ingin membuat REST API tradisional.
  • Endpoint-nya akan diakses dari client lain: mobile app, Postman, atau pihak ketiga.
  • Kamu butuh menangani berbagai method HTTP seperti GET, POST, PUT, DELETE.
  • Kamu ingin endpoint yang bisa diskalakan dan terpisah dari UI.

✅ Keunggulan Route Handlers

  • Fleksibel: mendukung semua metode HTTP standar.
  • Modular: setiap route bisa punya logic sendiri sesuai method.
  • Reusable: bisa dipanggil dari manapun, termasuk klien eksternal.
  • Lebih aman untuk komunikasi antar layanan dibanding Server Actions (yang hanya bisa dipanggil dari UI server-side).

Dengan pendekatan ini, kamu tetap bisa mengembangkan API yang scalable dan cocok untuk berbagai platform tanpa terikat pada struktur UI-komponen Next.js.


🧪 Apa Itu Server Actions?

Server Actions adalah fitur baru di Next.js 14+ yang memungkinkan kamu menjalankan fungsi async langsung di server tanpa perlu bikin API endpoint terpisah. Ini sangaat cocok untuk interaksi yang terjadi langsung dari UI, seperti form submission, tombol aksi, atau proses yang tidak perlu diekspos sebagai endpoint publik.

Bayangkan kamu bisa submit form, simpan data ke database, dan redirect user semua dalam satu fungsi, tanpa repot urus fetch() atau middleware.

✨ Contoh Sederhana

"use server";

import { urlAllProduct } from "@/lib/endpoint";

type AddProduct = {
  title: string;
  description: string;
};

export async function addProduct(_prev: AddProduct, formData: FormData) {
  const title = formData.get("title");
  const description = formData.get("description");
  const data = await fetch(`${urlAllProduct}/add`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      title,
      description,
    }),
  });

  const product = await data.json();
  console.log("new product:", product);

  return product;
}

Kemudian kamu panggil langsung dari komponen server:

"use client";

import { addProduct } from "@/actions/product/add";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useActionState } from "react";

const FormAddProduct = () => {
  const [state, action, isLoading] = useActionState(addProduct, {
    title: "",
    description: "",
  });
  return (
    <div className="flex flex-col">
      <form action={action} className="space-y-4">
        <div className="grid w-full gap-2">
          <Label htmlFor="title">Nama Produk</Label>
          <Input
            id="title"
            name="title"
            placeholder="Contoh: Kemeja Flanel Pria"
            required
          />
        </div>

        <div className="grid w-full gap-2">
          <Label htmlFor="description">Deskripsi Produk</Label>
          <Textarea
            id="description"
            name="description"
            placeholder="Contoh: Kemeja bahan flanel, tersedia dalam berbagai ukuran..."
            rows={4}
            required
          />
        </div>

        <Button type="submit" className="w-full" disabled={isLoading}>
          Simpan Produk
        </Button>
      </form>
      <p className="mt-6">{JSON.stringify(state)}</p>
    </div>
  );
};

export default FormAddProduct;

🕰️ Kapan Harus Dipakai?

Gunakan Server Actions ketika:

  • Form submission terjadi langsung dari komponen.
  • Aksi bersifat internal dan tidak perlu exposed sebagai public API.
  • Kamu ingin menulis logika server-only yang langsung dihubungkan ke UI.
  • Kamu ingin memanfaatkan fitur seperti redirect() atau revalidatePath() setelah aksi dilakukan.

✅ Keunggulan Server Actions

  • Tanpa API terpisah: semua logika tetap dekat dengan UI.
  • Sederhana dan cepat: cocok untuk prototyping maupun produk nyata.
  • Direct integration dengan form dan action di komponen.
  • Performa: bisa lebih efisien karena langsung dieksekusi di server.

Dengan Server Actions, kamu bisa menulis backend logic yag terasa seperti bagian alami dari UI — tanpa mental overhead harus mikir “buat endpoint lagi nggak, ya?”


⚖️ Perbandingan: Route Handlers vs Server Actions

Berikut perbandingan dua pendekatan ini berdasarkan berbagai aspek penting:

🔹 Lokasi Penulisan

  • Route Handlers: Ditulis di dalam folder app/api/*.
  • Server Actions: Ditulis langsung di komponen server atau file aksi (.ts) dengan "use server".

🔹 Bentuk dan Gaya

  • Route Handlers: Menggunakan method HTTP seperti GET, POST, PUT, DELETE.
  • Server Actions: Menggunakan async function biasa yang langsung dipanggil dari form atau UI komponen.

🔹 Akses dari Client

  • Route Handlers: Bisa dipanggil dari client-side dengan fetch(), Postman, mobile app, dan lainnya.
  • Server Actions: Tidak bisa dipanggil dari client langsung, hanya dari server-side komponen.

🔹 Tujuan dan Use Case

  • Route Handlers: Cocok untuk API publik, digunakan lintas platform (web, mobile).
  • Server Actions: Cocok untuk interaksi UI, seperti form submission internal dan aksi terbatas di komponen.

🔹 Middleware & Auth Handling

  • Route Handlers: Bisa menggunakan middleware Next.js (middleware.ts) untuk autentikasi atau rate limiting.
  • Server Actions: Harus handle otentikasi dan validasi manual di dalam fungsi itu sendiri.

🧭 Kapan Harus Pakai yang Mana?

Memilih antara Route Handlers dan Server Actions tergantung pada konteks dan kebutuhan aplikasimu. Berikut panduan praktisnya:


✅ Gunakan Route Handlers ketika:

  • 🔁 Kamu membutuhkan endpoint publik yang bisa diakses oleh:
    • Mobile App
    • Postman / API testing tool
    • Klien lain (frontend, pihak ketiga)
  • 📦 Kamu ingin membuat API yang mendukung berbagai metode HTTP (GET, POST, PUT, DELETE, dsb.).
  • 🧠 Kamu butuh kontrol penuh atas Request dan Response (headers, status code, content type).
  • 🔐 Ingin integrasi dengan middleware seperti otentikasi, rate limiting, dsb.

✅ Gunakan Server Actions ketika:

  • 📝 Kamu ingin menangani form submission langsung dari UI server component.
  • 🔒 Aksi hanya untuk kebutuhan internal (tidak perlu dibuka sebagai endpoint publik).
  • 🚀 Kamu ingin kode yang lebih ringkas dan efisien — tanpa membuat file API terpisah.
  • 🔁 Kamu butuh akses langsung ke fungsi redirect(), revalidatePath(), atau manipulasi response spesifik setelah aksi.

Gunakan pendekatan yang paling sesuai dengan konteks proyekmu — tidak ada yang selalu benar atau salah. Yang penting: pahami karakter masing-masing dan manfaatkannya secara optimal.


💡 Tutorial Proyek: Katalog Produk dengan Next.js + Server Actions + Route Handlers

Untuk benar-benar memahami kapan harus pakai Route Handlers atau Server Actions, mari kita lihat dua studi kasus sederhana yang sering dijumpai di proyek nyata.

📦 1. Instalasi

Kita buat proyek baru dengan App Router

npx create-next-app@latest bwa-dev

Buka direktori proyek

cd bwa-dev

Sekarang install shadcn

npx shadcn@latest init

Tambahkan beberapa komponen shadcn yang kita butuhkan

npx shadcn@latest add button input label textarea

📁 Struktur Folder

src/
├── actions/
│   └── product/
│       └── add.ts              → Server Action
├── app/
│   └── api/
│       ├── category/
│       │   └── route.ts        → Route Handler
│       └── product/
│           ├── route.ts        → GET all
│           └── [id]/
│               └── route.ts    → GET detail by ID
│
│   └── product/
│       ├── [id]/page.tsx       → Detail Produk
│       ├── [id]/not-found.tsx  → Fallback error
│       ├── add/
│       │   ├── form.tsx        → Komponen form
│       │   └── page.tsx        → Halaman form
│       ├── page.tsx            → Semua produk
│       └── template.tsx        → Template layout
│
├── components/
│   ├── category-list.tsx       → Daftar kategori
│   ├── category.tsx            → Card kategori
│   ├── product-list.tsx        → List produk
│   ├── product-card.tsx        → Card produk
│   └── ui/                     → Shadcn UI base
│       ├── button.tsx
│       ├── input.tsx
│       ├── label.tsx
│       └── textarea.tsx

├── lib/
│   ├── endpoint.ts             → API base URL
│   └── utils.ts                → Helper (opsional)
├── types/
│   ├── category.ts             → Tipe kategori
│   └── product.ts              → Tipe produk

Penjelasan:

Kita pisahkan logic Server Action, Route Handler, komponen UI, dan tipe data agar proyek lebih mudah dimaintain dan scalable.


🔧 2. Server Action: Tambah Produk

// src/actions/product/add.ts
"use server";

import { urlAllProduct } from "@/lib/endpoint";

type AddProduct = {
  title: string;
  description: string;
};

export async function addProduct(_prev: AddProduct, formData: FormData) {
  const title = formData.get("title");
  const description = formData.get("description");
  const data = await fetch(`${urlAllProduct}/add`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      title,
      description,
    }),
  });

  const product = await data.json();
  console.log("new product:", product);

  return product;
}

Fungsi ini adalah sebuah Server Action yang digunakan untuk menambahkan produk baru ke sistem melalui fetch ke endpoint API (/add). Fungsi ini bisa dipanggil langsung dari form di komponen server atau client dalam Next.js 14+ dengan App Router.


🌐 3. Route Handlers (API)

// src/app/api/product/route.ts
import { urlAllProduct, urlProductByCategory } from "@/lib/endpoint";
import { type NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const kategori = searchParams.get("kategori");
  const url = kategori ? urlProductByCategory(kategori) : urlAllProduct;
  const data = await fetch(url, { cache: "no-store" });
  return NextResponse.json(await data.json());
}

Kode ini mendefinisikan sebuah Route Handler di Next.js untuk menangani request GET ke /api/product. Ini berfungsi layaknya endpoint API tradisional, tapi dengan pendekatan baru dari App Router (app/api/...). Jika ada parameter kategori, maka pakai URL untuk mengambil produk berdasarkan kategori.


// src/app/api/product/[id]/route.ts
import { urlSingleProduct } from "@/lib/endpoint";
import { type NextRequest, NextResponse } from "next/server";

export async function GET(
  _request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const data = await fetch(urlSingleProduct(id), { cache: "no-store" });
  const body = await data.json();

  if (body.message) {
    return NextResponse.json({ error: body.message }, { status: 404 });
  }

  return NextResponse.json(body);
}

Route handler ini menangani request GET ke endpoint dinamis /api/product/[id], yang berfungsi untuk mengambil detail satu produk berdasarkan ID-nya.


// src/app/api/category/route.ts
import { urlAllCategories } from "@/lib/endpoint";
import { NextResponse } from "next/server";

export async function GET() {
  const data = await fetch(urlAllCategories, { cache: "no-store" });
  return NextResponse.json(await data.json());
}

Fungsi ini adalah route API yang menangani permintaan GET untuk mengambil semua kategori dari sumber data eksternal (misalnya pada tutorial ini kita ambil data dari https://dummyjson.com).


🧱 4. Form Tambah Produk

// src/app/product/add/form.tsx
"use client";

import { addProduct } from "@/actions/product/add";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useActionState } from "react";

const FormAddProduct = () => {
  const [state, action, isLoading] = useActionState(addProduct, {
    title: "",
    description: "",
  });
  return (
    <div className="flex flex-col">
      <form action={action} className="space-y-4">
        <div className="grid w-full gap-2">
          <Label htmlFor="title">Nama Produk</Label>
          <Input
            id="title"
            name="title"
            placeholder="Contoh: Kemeja Flanel Pria"
            required
          />
        </div>

        <div className="grid w-full gap-2">
          <Label htmlFor="description">Deskripsi Produk</Label>
          <Textarea
            id="description"
            name="description"
            placeholder="Contoh: Kemeja bahan flanel, tersedia dalam berbagai ukuran..."
            rows={4}
            required
          />
        </div>

        <Button type="submit" className="w-full" disabled={isLoading}>
          Simpan Produk
        </Button>
      </form>
      <p className="mt-6">{JSON.stringify(state)}</p>
    </div>
  );
};

export default FormAddProduct;

Komponen ini merupakan form input produk yang berjalan di client ("use client"), menggunakan Server Action addProduct, dan menampilkan response dari server setelah submit dengan bantuan useActionState.


// src/app/product/add/page.tsx
import FormAddProduct from "./form";

export default function AddProductPage() {
  return (
    <div className="max-w-md mx-auto mt-10 bg-white p-6 rounded-2xl shadow-lg border">
      <h1 className="text-2xl font-semibold mb-6">Tambah Produk Baru</h1>
      <FormAddProduct />
    </div>
  );
}

File ini adalah Server Component yang merender halaman untuk menampilkan form tambah produk di route /product/add.

Halaman tambah produk
Halaman tambah produk

Produk yang kita tambahkan tidak akan ditambahkan ke server, hanya sebagai simulasi POST request.

Products - DummyJSON - Free Fake REST API for Placeholder JSON Data
Products - DummyJSON - Free Fake REST API for Placeholder JSON Data

📦 5. Halaman List Produk

// src/app/product/page.tsx
import { CategoryList } from "@/components/category-list";
import { ProductList } from "@/components/product-list";
import { Suspense } from "react";

interface ProductPageProps {
  searchParams: Promise<{
    kategori?: string;
  }>;
}

export default async function ProductPage({ searchParams }: ProductPageProps) {
  // throw new Error("Simulasi error untuk demo");
  const { kategori } = await searchParams;
  return (
    <div>
      <Suspense fallback={<p>Loading...</p>}>
        <CategoryList />
      </Suspense>
      <Suspense fallback={<p>Loading product..</p>}>
        <ProductList kategori={kategori} />
      </Suspense>
    </div>
  );
}

Halaman ini menampilkan daftar kategori produk melalui komponen CategoryList, serta daftar produk yang difilter berdasarkan kategori tertentu menggunakan komponen ProductList. Filtering ini dilakukan dengan memanfaatkan fitur searchParams, sehingga data dapat difilter melalui URL query seperti ?kategori=elektronik.


// src/components/product-list.tsx
import { ProductList as List } from "@/types/product";
import { ProductCard } from "./product-card";

interface ProductListProps {
  kategori?: string;
}

export async function ProductList({ kategori }: ProductListProps) {
  const params = new URLSearchParams();
  if (kategori) params.set("kategori", kategori);

  const url = `http://localhost:3000/api/product${
    params.toString() ? `?${params}` : ""
  }`;
  const response = await fetch(url);

  const allProducts = (await response.json()) as List;

  if (allProducts.products.length === 0) {
    return (
      <div className="text-center text-muted-foreground py-12">
        Tidak ada produk ditemukan.
      </div>
    );
  }

  return (
    <div className="mx-5 my-8 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-6">
      {allProducts.products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

Komponen ProductList adalah Server Component yang mengambil data produk dari Route Handler /api/product, lalu menampilkannya dalam grid. Jika ada query kategori, URL difilter pakai search params. Data difetch langsung di server menggunakan fetch() (server-side fetching). Tidak menggunakan Server Action.

Haman daftar produk
Haman daftar produk

🔍 6. Halaman Detail Produk

// src/app/product/[id]/page.tsx
import { notFound } from "next/navigation";
import Image from "next/image";
import { Product } from "@/types/product";

interface DetailPageProps {
  params: Promise<{
    id: string;
  }>;
}

export default async function DetailPage({ params }: DetailPageProps) {
  const { id } = await params;

  const data = await fetch(`http://localhost:3000/api/product/${id}`);

  if (data.status === 404) notFound();

  const product = (await data.json()) as Product;

  if (!product) return notFound();

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <div className="flex flex-col md:flex-row gap-8">
        <Image
          src={product.thumbnail}
          alt={product.title}
          width={400}
          height={400}
          className="object-cover rounded-md shadow-md"
        />

        <div className="flex-1 space-y-4">
          <h1 className="text-3xl font-bold">{product.title}</h1>
          <p className="text-gray-600">{product.description}</p>

          <div className="text-xl font-semibold text-green-600">
            ${product.price}{" "}
            <span className="text-sm text-gray-500 line-through ml-2">
              $
              {Math.round(
                product.price / (1 - product.discountPercentage / 100)
              )}
            </span>
            <span className="ml-2 text-sm text-red-500">
              -{product.discountPercentage}%
            </span>
          </div>

          <div className="flex items-center gap-2">
            <span className="text-yellow-500">⭐ {product.rating}</span>
            <span className="text-sm text-gray-500">
              ({product.stock} stock)
            </span>
          </div>

          <div className="text-sm text-gray-500">
            <p>
              <strong>Brand:</strong> {product.brand}
            </p>
            <p>
              <strong>Category:</strong> {product.category}
            </p>
            <p>
              <strong>SKU:</strong> {product.sku}
            </p>
            <p>
              <strong>Weight:</strong> {product.weight} g
            </p>
            <p>
              <strong>Dimensions:</strong> {product.dimensions.width}×
              {product.dimensions.height}×{product.dimensions.depth} cm
            </p>
          </div>

          <div>
            <strong className="text-sm">Tags:</strong>
            <div className="flex flex-wrap gap-2 mt-1">
              {product.tags.map((tag) => (
                <span
                  key={tag}
                  className="px-2 py-1 bg-gray-200 text-sm rounded-md"
                >
                  #{tag}
                </span>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

Halaman ini adalah Server Component untuk menampilkan detail produk berdasarkan ID. Data diambil menggunakan Route Handler /api/product/[id] melalui fetch() server-side. Jika respons 404 atau data kosong, akan diarahkan ke notFound(). Tidak menggunakan Server Action.

Halaman detail produk
Halaman detail produk

📚 7. Tipe Produk

// src/types/product.ts
export interface Product {
  id: number;
  title: string;
  description: string;
  category: string;
  price: number;
  discountPercentage: number;
  rating: number;
  stock: number;
  tags: string[];
  brand: string;
  sku: string;
  weight: number;
  dimensions: {
    width: number;
    height: number;
    depth: number;
  };
  thumbnail: string;
}

export interface ProductList {
  products: Product[];
  total: number;
  skip: number;
  limit: number;
}

Tipe TypeScript ini memastikan struktur data produk selalu konsisten di seluruh proyek.


📚 8. Tipe Category

// src/types/category.ts
export interface Category {
  slug: string;
  name: string;
  ulr: string;
}

Interface Category mendefinisikan struktur data kategori


9. Endpoint

// src/lib/endpoint.ts
export const urlAllProduct = "<https://dummyjson.com/products>";

export const urlAllCategories = `${urlAllProduct}/categories`;

export const urlSingleProduct = (id: string) => `${urlAllProduct}/${id}`;

export const urlProductByCategory = (category: string) =>
  `${urlAllProduct}/category/${category}`;

File ini berisi kumpulan URL endpoint eksternal (dummyjson.com) untuk produk dan kategori. Digunakan di seluruh aplikasi untuk fetch() data dari API. Penulisan ini menjaga konsistensi dan memudahkan pemeliharaan kode.


Dengan tutorial ini, kamu sudah:

  • Memahami perbedaan Server Action (form langsung di UI) dan Route Handler (API terpisah)
  • Belajar membuat katalog produk CRUD dasar
  • Mengenal struktur proyek profesional yang rapi

Berikut versi lengkap dan ringkas dari bagian Best Practices:


🧩 Best Practices

  • Pilih salah satu pendekatan sesuai kebutuhan, hindari mencampur Route Handlers dan Server Actions dalam satu flow tanpa alasan kuat.
  • Gunakan Server Actions untuk form submission atau aksi yang hanya terjadi di sisi server & internal UI.
  • Gunakan Route Handlers jika datanya perlu diakses lintas platform (web, mobile, Postman, dll) atau membutuhkan endpoint RESTful standar.
  • Hindari duplikasi logic antara Server Actions dan Route Handlers — pisahkan fungsi reusable di helper atau service.
  • Selalu perhatikan keamanan data (auth, validasi) di kedua pendekatan karena tetap bisa diakses lewat HTTP request.

✅ Penutup

Dengan memahami perbedaan antara Route Handlers dan Server Actions di Next.js, kita bisa membangun arsitektur aplikasi yang lebih efisien, terstruktur, dan mudah di-maintain. Memilih pendekatan yang tepat bukan hanya soal gaya coding, tapi juga soal performa, skalabilitas, dan kemudahan pengembangan jangka panjang.

Jika Anda ingin memperdalam pemahaman Next.js dan membangun aplikasi modern bersama mentor expert, Anda bisa belajar di BuildWithAngga.

Di sana, Anda akan mendapatkan akses materi selamanya, konsultasi langsung dengan mentor, membangun portfolio yang nyata, dan berbagai benefit menarik lainnya.

Mari belajar bareng dan wujudkan cita-cita menjadi web developer profesional bersama BuildWithAngga!