Route Handlers di Next.js 16: Panduan Lengkap REST API, Webhook, dan Streaming

Panduan lengkap Route Handlers di Next.js 16 — dari REST API CRUD, validasi Zod, CORS, streaming SSE, webhook Stripe, file upload, caching dengan use cache, hingga rate limiting. Semua dengan contoh kode siap produksi.

Pendahuluan: Mengapa Route Handlers Masih Relevan di Next.js 16

Kalau Anda sudah mengikuti seri panduan kami — mulai dari fitur-fitur utama Next.js 16, Server Actions, hingga Data Fetching & Caching — mungkin ada satu pertanyaan yang mengganjal: kalau Server Actions sudah bisa menangani mutasi data, lalu kapan kita masih butuh bikin API endpoint sendiri?

Jawabannya? Lebih sering dari yang Anda kira.

Server Actions memang luar biasa untuk operasi internal — form submission, mutasi database yang dipicu dari UI, hal-hal semacam itu. Tapi ada banyak skenario di dunia nyata yang nggak bisa (dan memang tidak seharusnya) ditangani oleh Server Actions. Contohnya: webhook dari Stripe yang perlu endpoint publik, mobile app yang butuh REST API, streaming response untuk integrasi AI/LLM, atau sekadar CORS-enabled endpoint yang bisa dipanggil dari domain lain.

Nah, di sinilah Route Handlers masuk.

Route Handlers adalah mekanisme bawaan Next.js App Router untuk membuat custom HTTP endpoint menggunakan standard Web API — Request dan Response. Mereka menggantikan API Routes dari Pages Router, dan di Next.js 16 mereka dapat beberapa peningkatan signifikan termasuk RouteContext dengan typed params dan integrasi "use cache".

Dalam panduan ini, kita akan bahas Route Handlers dari A sampai Z — mulai dari setup dasar, pola CRUD, validasi input, CORS, streaming & SSE, webhook handling, file upload, hingga strategi caching dan rate limiting. Semua dilengkapi contoh kode yang bisa langsung Anda pakai di produksi. Jadi, langsung saja kita mulai.

Struktur File dan Konvensi Dasar

Dari pages/api ke app/api: Perubahan Paradigma

Jika Anda pernah menggunakan Pages Router, pasti sudah familiar dengan folder pages/api/ untuk membuat API endpoint. Di App Router, konsep ini digantikan oleh file route.ts (atau route.js) yang bisa ditempatkan di mana saja dalam direktori app/.

// Struktur folder
app/
├── api/
│   ├── users/
│   │   ├── route.ts          // GET /api/users, POST /api/users
│   │   └── [id]/
│   │       └── route.ts      // GET /api/users/:id, PUT, DELETE
│   ├── webhooks/
│   │   └── stripe/
│   │       └── route.ts      // POST /api/webhooks/stripe
│   └── stream/
│       └── route.ts          // GET /api/stream (SSE)
├── products/
│   ├── page.tsx
│   └── route.ts              // ❌ TIDAK BOLEH! Konflik dengan page.tsx
└── layout.tsx

Ada satu aturan penting yang sering bikin developer bingung: file route.ts dan page.tsx tidak boleh berada di segment route yang sama. Kalau Anda punya app/products/page.tsx, maka app/products/route.ts akan menyebabkan konflik. Solusinya simpel — pindahkan route handler ke sub-folder seperti app/api/products/route.ts.

Anatomi Route Handler

Sebuah route handler pada dasarnya cuma file yang mengekspor fungsi-fungsi dengan nama sesuai HTTP method yang ingin ditangani:

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";

// GET /api/users
export async function GET(request: NextRequest) {
  const users = await db.users.findMany();
  return NextResponse.json(users);
}

// POST /api/users
export async function POST(request: NextRequest) {
  const body = await request.json();
  const user = await db.users.create({ data: body });
  return NextResponse.json(user, { status: 201 });
}

HTTP method yang didukung: GET, POST, PUT, PATCH, DELETE, HEAD, dan OPTIONS. Jika ada request dengan method yang nggak diekspor, Next.js otomatis mengembalikan response 405 Method Not Allowed — jadi Anda nggak perlu handle itu secara manual. Next.js juga mengimplementasikan OPTIONS secara otomatis kalau Anda tidak mendefinisikannya sendiri.

Membangun REST API CRUD Lengkap

Endpoint Koleksi: List dan Create

Oke, mari kita bangun sesuatu yang lebih nyata. Kita akan buat API produk lengkap. Pertama, endpoint untuk mengambil daftar produk dan membuat produk baru:

// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";

// Schema validasi dengan Zod
const createProductSchema = z.object({
  name: z.string().min(1, "Nama produk wajib diisi").max(200),
  price: z.number().positive("Harga harus lebih dari 0"),
  description: z.string().optional(),
  categoryId: z.string().uuid("Format kategori tidak valid"),
});

// GET /api/products — Daftar produk dengan pagination
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const page = parseInt(searchParams.get("page") ?? "1");
  const limit = parseInt(searchParams.get("limit") ?? "20");
  const search = searchParams.get("search");

  const offset = (page - 1) * limit;

  const where = search
    ? { name: { contains: search, mode: "insensitive" as const } }
    : {};

  const [products, total] = await Promise.all([
    db.product.findMany({
      where,
      skip: offset,
      take: limit,
      orderBy: { createdAt: "desc" },
    }),
    db.product.count({ where }),
  ]);

  return NextResponse.json({
    data: products,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  });
}

// POST /api/products — Buat produk baru
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const validated = createProductSchema.parse(body);

    const product = await db.product.create({
      data: validated,
    });

    return NextResponse.json(product, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: "Validasi gagal", details: error.errors },
        { status: 400 }
      );
    }
    return NextResponse.json(
      { error: "Gagal membuat produk" },
      { status: 500 }
    );
  }
}

Ada beberapa hal penting dari kode di atas. Pertama, kita pakai Zod untuk validasi input — ini menurut saya wajib hukumnya. Jangan pernah percaya data dari klien tanpa validasi, apalagi di endpoint publik. Kedua, perhatikan penggunaan Promise.all() untuk menjalankan query data dan count secara paralel. Tanpa ini, dua query tersebut berjalan sekuensial dan response pagination jadi lebih lambat dari yang seharusnya.

Endpoint Individual: Read, Update, Delete

Untuk operasi pada produk individual, kita menggunakan dynamic route segment:

// app/api/products/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";

type RouteParams = { params: Promise<{ id: string }> };

const updateProductSchema = z.object({
  name: z.string().min(1).max(200).optional(),
  price: z.number().positive().optional(),
  description: z.string().optional(),
  categoryId: z.string().uuid().optional(),
});

// GET /api/products/:id
export async function GET(request: NextRequest, { params }: RouteParams) {
  const { id } = await params;

  const product = await db.product.findUnique({
    where: { id },
    include: { category: true },
  });

  if (!product) {
    return NextResponse.json(
      { error: "Produk tidak ditemukan" },
      { status: 404 }
    );
  }

  return NextResponse.json(product);
}

// PUT /api/products/:id
export async function PUT(request: NextRequest, { params }: RouteParams) {
  const { id } = await params;

  try {
    const body = await request.json();
    const validated = updateProductSchema.parse(body);

    const product = await db.product.update({
      where: { id },
      data: validated,
    });

    return NextResponse.json(product);
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: "Validasi gagal", details: error.errors },
        { status: 400 }
      );
    }
    return NextResponse.json(
      { error: "Gagal memperbarui produk" },
      { status: 500 }
    );
  }
}

// DELETE /api/products/:id
export async function DELETE(request: NextRequest, { params }: RouteParams) {
  const { id } = await params;

  try {
    await db.product.delete({ where: { id } });
    return new Response(null, { status: 204 });
  } catch (error) {
    return NextResponse.json(
      { error: "Gagal menghapus produk" },
      { status: 500 }
    );
  }
}

Satu hal yang perlu dicatat — di Next.js 16, params bersifat asinkron. Anda harus await untuk mengakses nilainya. Ini perubahan dari versi sebelumnya dan jujur, ini salah satu breaking change yang paling sering terlewat saat migrasi. Saya sendiri pernah menghabiskan waktu cukup lama debugging issue ini sebelum sadar kalau params sekarang harus di-await.

RouteContext: Typed Params yang Lebih Aman

Next.js 16 juga memperkenalkan RouteContext — sebuah type helper global yang memberikan type safety penuh untuk parameter route:

// app/api/products/[id]/reviews/[reviewId]/route.ts
export async function GET(
  request: NextRequest,
  ctx: RouteContext<"/api/products/[id]/reviews/[reviewId]">
) {
  const { id, reviewId } = await ctx.params;
  // id dan reviewId sudah ter-type sebagai string secara otomatis

  const review = await db.review.findFirst({
    where: { productId: id, id: reviewId },
  });

  return NextResponse.json(review);
}

Dengan RouteContext, TypeScript tahu persis parameter apa saja yang tersedia berdasarkan pola route literal yang Anda definisikan. Nggak perlu lagi mendefinisikan type params secara manual — compiler yang urus semuanya. Ini salah satu quality-of-life improvement yang kecil tapi terasa banget dampaknya.

Menangani CORS untuk Akses Lintas Domain

CORS Per-Route

Jika API Anda diakses dari domain lain — misalnya frontend terpisah atau mobile app — Anda perlu mengonfigurasi CORS headers. Berikut cara implementasinya di level route handler:

// app/api/public/products/route.ts
const ALLOWED_ORIGINS = [
  "https://myapp.com",
  "https://admin.myapp.com",
];

function getCorsHeaders(origin: string | null) {
  const headers: Record<string, string> = {
    "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Max-Age": "86400",
  };

  if (origin && ALLOWED_ORIGINS.includes(origin)) {
    headers["Access-Control-Allow-Origin"] = origin;
  }

  return headers;
}

// Tangani preflight request
export async function OPTIONS(request: NextRequest) {
  const origin = request.headers.get("origin");
  return new Response(null, {
    status: 204,
    headers: getCorsHeaders(origin),
  });
}

export async function GET(request: NextRequest) {
  const origin = request.headers.get("origin");
  const products = await db.product.findMany();

  return NextResponse.json(products, {
    headers: getCorsHeaders(origin),
  });
}

Ini poin yang sering banget terlewat: Anda wajib mengekspor handler OPTIONS untuk menangani preflight request. Tanpa handler ini, browser akan memblokir request lintas domain karena preflight gagal. Memang Next.js mengimplementasikan OPTIONS secara otomatis, tapi implementasi default-nya nggak menyertakan CORS headers — jadi tetap perlu custom handler.

CORS Global via Proxy (Middleware)

Kalau banyak route yang butuh CORS, menambahkan header di setiap handler jelas nggak praktis. Solusi yang lebih elegan adalah menggunakan proxy.ts (pengganti middleware.ts di Next.js 16):

// proxy.ts
import { NextRequest, NextResponse } from "next/server";

const ALLOWED_ORIGINS = ["https://myapp.com", "https://admin.myapp.com"];

export function proxy(request: NextRequest) {
  const origin = request.headers.get("origin");
  const response = NextResponse.next();

  if (origin && ALLOWED_ORIGINS.includes(origin)) {
    response.headers.set("Access-Control-Allow-Origin", origin);
    response.headers.set(
      "Access-Control-Allow-Methods",
      "GET, POST, PUT, DELETE, OPTIONS"
    );
    response.headers.set(
      "Access-Control-Allow-Headers",
      "Content-Type, Authorization"
    );
  }

  return response;
}

export const config = {
  matcher: "/api/:path*",
};

Dengan pendekatan ini, semua route di bawah /api/ otomatis mendapatkan CORS headers tanpa perlu konfigurasi satu per satu. Dan ingat — di Next.js 16, proxy.ts menggantikan middleware.ts dan berjalan di atas runtime Node.js, bukan Edge runtime. Perubahan yang cukup signifikan.

Streaming Response dan Server-Sent Events (SSE)

Mengapa Streaming Penting di Era AI

Dengan maraknya integrasi LLM dan AI di aplikasi web, kemampuan streaming response jadi semakin krusial. Coba bayangkan — pengguna nggak mau menunggu 10 detik untuk melihat seluruh respons AI sekaligus. Mereka ingin melihat token demi token mengalir secara real-time, persis seperti pengalaman di ChatGPT.

Route Handlers mendukung streaming melalui ReadableStream — Web API standar yang tersedia di semua runtime modern.

Implementasi SSE untuk Streaming Real-Time

// app/api/stream/route.ts
export const dynamic = "force-dynamic";

export async function GET() {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    start(controller) {
      let count = 0;
      const interval = setInterval(() => {
        const data = JSON.stringify({
          time: new Date().toISOString(),
          message: `Update ke-${++count}`,
        });
        controller.enqueue(encoder.encode(`data: ${data}\n\n`));

        if (count >= 10) {
          clearInterval(interval);
          controller.close();
        }
      }, 1000);
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "Content-Encoding": "none",
    },
  });
}

Header Content-Encoding: none itu penting banget. Tanpa header ini, beberapa proxy dan CDN (termasuk Vercel) bakal mencoba mengompresi response dan menahan chunk sampai stream selesai. Ironis, karena itu justru menghancurkan tujuan streaming itu sendiri.

Streaming Respons AI/LLM

Berikut pola yang lebih realistis — streaming response langsung dari LLM provider:

// app/api/chat/route.ts
import { openai } from "@/lib/openai";

export async function POST(request: NextRequest) {
  const { messages } = await request.json();

  const completion = await openai.chat.completions.create({
    model: "gpt-4o",
    messages,
    stream: true,
  });

  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      try {
        for await (const chunk of completion) {
          const content = chunk.choices[0]?.delta?.content;
          if (content) {
            controller.enqueue(
              encoder.encode(`data: ${JSON.stringify({ content })}\n\n`)
            );
          }
        }
        controller.enqueue(encoder.encode("data: [DONE]\n\n"));
        controller.close();
      } catch (error) {
        controller.error(error);
      }
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
    },
  });
}

Konsumsi SSE di Sisi Klien

Untuk mendengarkan stream dari React, gunakan EventSource API:

// components/live-feed.tsx
"use client";

import { useEffect, useState } from "react";

export default function LiveFeed() {
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    const source = new EventSource("/api/stream");

    source.onmessage = (event) => {
      const data = JSON.parse(event.data);
      setMessages((prev) => [...prev, data.message]);
    };

    source.onerror = () => {
      source.close();
    };

    return () => source.close();
  }, []);

  return (
    <ul>
      {messages.map((msg, i) => (
        <li key={i}>{msg}</li>
      ))}
    </ul>
  );
}

Perlu dicatat bahwa EventSource hanya mendukung method GET. Kalau Anda perlu mengirim data (misalnya prompt AI yang panjang) sebelum streaming dimulai, gunakan pola dua langkah: POST ke endpoint untuk membuat session ID, lalu buka EventSource ke /api/stream?sid=... untuk menerima hasilnya. Agak ribet memang, tapi ini workaround yang paling umum dipakai.

Menerima dan Memproses Webhook

Pattern Webhook yang Aman

Webhook adalah salah satu alasan utama mengapa Route Handlers nggak bisa digantikan oleh Server Actions. Layanan pihak ketiga seperti Stripe, GitHub, atau Resend mengirim POST request ke endpoint publik Anda — dan itu sesuatu yang cuma bisa dilakukan oleh Route Handlers.

// app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: NextRequest) {
  const body = await request.text();
  const headersList = await headers();
  const signature = headersList.get("stripe-signature");

  if (!signature) {
    return NextResponse.json(
      { error: "Signature tidak ditemukan" },
      { status: 400 }
    );
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    console.error("Verifikasi webhook gagal:", err);
    return NextResponse.json(
      { error: "Signature tidak valid" },
      { status: 400 }
    );
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await handleCheckoutCompleted(session);
      break;
    }
    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      await handlePaymentFailed(invoice);
      break;
    }
    default:
      console.log(`Event type tidak ditangani: ${event.type}`);
  }

  return NextResponse.json({ received: true });
}

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  await db.order.update({
    where: { stripeSessionId: session.id },
    data: { status: "paid", paidAt: new Date() },
  });
}

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  await db.subscription.update({
    where: { stripeCustomerId: invoice.customer as string },
    data: { status: "past_due" },
  });
}

Ada beberapa poin krusial untuk webhook handling yang harus Anda perhatikan:

  • Selalu verifikasi signature — tanpa ini, siapa pun bisa mengirim request palsu ke endpoint webhook Anda dan memicu aksi yang nggak diinginkan. Serius, jangan skip langkah ini.
  • Gunakan request.text(), bukan request.json() — verifikasi signature butuh raw body. Kalau Anda parse dulu dengan .json(), signature verification pasti gagal karena body sudah dikonsumsi.
  • Return 200 secepat mungkin — layanan webhook biasanya punya timeout pendek. Untuk operasi berat, kirim job ke background queue dan return 200 segera.
  • Buat idempotent — webhook bisa dikirim lebih dari sekali (retry mechanism). Pastikan memproses event yang sama dua kali nggak menyebabkan masalah.

Upload File via Route Handlers

Menerima File dari FormData

Upload file adalah kasus penggunaan klasik yang paling cocok ditangani Route Handlers karena butuh kontrol penuh atas request dan response:

// app/api/upload/route.ts
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { NextRequest, NextResponse } from "next/server";

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const file = formData.get("file") as File | null;

  if (!file) {
    return NextResponse.json(
      { error: "File tidak ditemukan" },
      { status: 400 }
    );
  }

  if (file.size > MAX_FILE_SIZE) {
    return NextResponse.json(
      { error: "Ukuran file melebihi batas 5MB" },
      { status: 400 }
    );
  }

  if (!ALLOWED_TYPES.includes(file.type)) {
    return NextResponse.json(
      { error: "Tipe file tidak didukung. Gunakan JPEG, PNG, atau WebP" },
      { status: 400 }
    );
  }

  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  const uniqueName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
  const uploadDir = join(process.cwd(), "public", "uploads");
  await mkdir(uploadDir, { recursive: true });
  const filePath = join(uploadDir, uniqueName);

  await writeFile(filePath, buffer);

  return NextResponse.json({
    url: `/uploads/${uniqueName}`,
    name: file.name,
    size: file.size,
  }, { status: 201 });
}

Yang menarik di sini: di Next.js App Router, request.formData() pakai Web API standar — Anda nggak perlu library pihak ketiga kayak multer atau busboy untuk kasus upload sederhana. Tapi untuk file berukuran besar atau upload streaming, library-library itu mungkin masih dibutuhkan.

Oh iya, satu catatan penting: untuk produksi, sebaiknya upload file langsung ke object storage seperti AWS S3 atau Cloudflare R2 pakai presigned URL. Jangan simpan ke filesystem lokal — terutama kalau Anda deploy ke Vercel atau platform serverless lain di mana filesystem bersifat ephemeral (hilang setiap kali function cold start).

Caching Route Handlers dengan "use cache"

GET Handler yang Di-cache

Ini salah satu fitur favorit saya di Next.js 16 — kemampuan pakai direktif "use cache" langsung di Route Handlers. Di versi sebelumnya, ini nggak bisa dilakukan. Sangat berguna untuk GET endpoint yang mengembalikan data yang jarang berubah:

// app/api/categories/route.ts
import { cacheLife, cacheTag } from "next/cache";
import { NextResponse } from "next/server";

export async function GET() {
  "use cache";
  cacheLife("hours");
  cacheTag("categories");

  const categories = await db.category.findMany({
    orderBy: { name: "asc" },
  });

  return NextResponse.json(categories);
}

Ketika Cache Components diaktifkan (cacheComponents: true di next.config.ts), GET Route Handlers mengikuti model yang sama dengan route UI — secara default berjalan saat request masuk, bisa di-prerender jika nggak mengakses data dinamis, dan bisa pakai "use cache" untuk menyertakan data dinamis dalam respons statis.

Menggabungkan use cache dengan Dynamic Params

Untuk route handler dengan dynamic params, Anda perlu sedikit kreativitas karena params bersifat Promise di Next.js 16:

// app/api/products/[id]/route.ts
import { cacheLife, cacheTag } from "next/cache";

async function getCachedProduct(id: Promise<string>) {
  "use cache";
  cacheLife("hours");
  const resolvedId = await id;
  cacheTag(`product-${resolvedId}`);

  return await db.product.findUnique({
    where: { id: resolvedId },
    include: { category: true, reviews: true },
  });
}

export async function GET(
  request: NextRequest,
  ctx: RouteContext<"/api/products/[id]">
) {
  const product = await getCachedProduct(ctx.params.then((p) => p.id));

  if (!product) {
    return NextResponse.json(
      { error: "Produk tidak ditemukan" },
      { status: 404 }
    );
  }

  return NextResponse.json(product);
}

Triknya: buat fungsi helper terpisah dengan direktif "use cache", lalu teruskan params sebagai Promise menggunakan .then(). Dengan cara ini, Next.js bisa generate cache key berdasarkan parameter yang sudah di-resolve, dan hasilnya di-cache per produk secara individual. Sedikit tricky, tapi hasilnya sangat worth it.

Invalidasi Cache dari Server Action

Setelah data di-cache, tentu Anda perlu cara untuk menginvalidasinya saat ada perubahan. Dan ini contoh kolaborasi yang elegan antara Server Actions dan Route Handlers:

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

import { revalidateTag } from "next/cache";

export async function updateProduct(id: string, data: ProductUpdateInput) {
  await db.product.update({ where: { id }, data });

  // Invalidasi cache untuk produk spesifik DAN daftar kategori
  revalidateTag(`product-${id}`);
  revalidateTag("categories");
}

Server Actions menangani mutasi dan invalidasi cache, sementara Route Handlers menyajikan data yang di-cache dengan efisien. Kedua fitur ini bukan kompetitor — mereka justru pasangan yang saling melengkapi.

Pola Reusable: Higher-Order Functions untuk Route Handlers

Wrapper untuk Auth, Logging, dan Error Handling

Seiring bertambahnya jumlah endpoint, Anda pasti akan menyadari bahwa banyak pola yang berulang: pengecekan autentikasi, logging, error handling, rate limiting. Daripada copy-paste kode yang sama ke setiap handler, gunakan Higher-Order Function (HOF) sebagai wrapper:

// lib/api-handler.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth";

type ApiHandler = (
  request: NextRequest,
  context: { params: Promise<Record<string, string>>; user: User }
) => Promise<Response>;

export function withAuth(handler: ApiHandler) {
  return async (
    request: NextRequest,
    context: { params: Promise<Record<string, string>> }
  ) => {
    const session = await getSession();

    if (!session?.user) {
      return NextResponse.json(
        { error: "Autentikasi diperlukan" },
        { status: 401 }
      );
    }

    try {
      return await handler(request, {
        ...context,
        user: session.user,
      });
    } catch (error) {
      console.error(`[API Error] ${request.method} ${request.url}:`, error);
      return NextResponse.json(
        { error: "Terjadi kesalahan internal" },
        { status: 500 }
      );
    }
  };
}

// Penggunaan:
// app/api/admin/users/route.ts
export const GET = withAuth(async (request, { user }) => {
  if (user.role !== "admin") {
    return NextResponse.json({ error: "Akses ditolak" }, { status: 403 });
  }

  const users = await db.user.findMany();
  return NextResponse.json(users);
});

Pola ini bikin setiap handler tetap fokus pada logika bisnisnya saja. Sementara concern umum seperti autentikasi dan error handling ditangani secara konsisten di satu tempat. Bonus: testing juga jadi lebih mudah karena wrapper bisa di-mock secara terpisah.

Rate Limiting di Produksi

Implementasi Dasar dengan In-Memory Store

Rate limiting itu penting untuk mencegah penyalahgunaan API. Berikut implementasi sederhana yang bisa jadi titik awal:

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

export function checkRateLimit(
  identifier: string,
  limit: number = 60,
  windowMs: number = 60_000
): { allowed: boolean; remaining: number; resetIn: number } {
  const now = Date.now();
  const record = rateLimit.get(identifier);

  if (!record || now > record.resetTime) {
    rateLimit.set(identifier, { count: 1, resetTime: now + windowMs });
    return { allowed: true, remaining: limit - 1, resetIn: windowMs };
  }

  record.count++;

  if (record.count > limit) {
    return {
      allowed: false,
      remaining: 0,
      resetIn: record.resetTime - now,
    };
  }

  return {
    allowed: true,
    remaining: limit - record.count,
    resetIn: record.resetTime - now,
  };
}
// Penggunaan di route handler
export async function POST(request: NextRequest) {
  const ip = request.headers.get("x-forwarded-for") ?? "unknown";
  const { allowed, remaining, resetIn } = checkRateLimit(ip, 10, 60_000);

  if (!allowed) {
    return NextResponse.json(
      { error: "Terlalu banyak request. Coba lagi nanti." },
      {
        status: 429,
        headers: {
          "Retry-After": String(Math.ceil(resetIn / 1000)),
          "X-RateLimit-Remaining": "0",
        },
      }
    );
  }

  // ... logika handler normal
}

Catatan penting: pendekatan in-memory ini cuma cocok untuk single-instance deployment. Di lingkungan serverless seperti Vercel Functions atau setup multi-instance, setiap instance punya memory sendiri sehingga rate limit nggak bisa di-share antar instance. Untuk produksi yang sesungguhnya, pakai Redis atau sejenisnya sebagai shared store — Upstash Redis misalnya, yang punya REST API sehingga bisa dipanggil dari serverless functions maupun Edge runtime.

Route Handlers vs Server Actions: Panduan Keputusan

Setelah mempelajari kedua fitur secara mendalam, ini panduan praktis yang bisa Anda jadikan referensi:

Skenario Pilihan Alasan
Form submission dari komponen React Server Action Integrasi langsung, type-safe, progressive enhancement
Mutasi data internal (CRUD dalam app) Server Action Tidak perlu endpoint terpisah, revalidasi otomatis
API untuk mobile app atau frontend terpisah Route Handler Butuh URL endpoint yang bisa diakses dari luar
Webhook (Stripe, GitHub, dll.) Route Handler Layanan eksternal tidak bisa memanggil Server Actions
Streaming / SSE Route Handler Butuh kontrol penuh atas Response stream
File download / export (CSV, PDF) Route Handler Butuh header kustom dan binary response
Endpoint dengan CORS Route Handler Server Actions tidak mendukung CORS
File upload Keduanya Server Action untuk form sederhana, Route Handler untuk kontrol lebih

Aturan praktisnya cukup sederhana: jika cuma aplikasi Next.js Anda yang memanggil fungsi tersebut, mulai dengan Server Action. Jika butuh endpoint yang bisa diakses dari luar, atau butuh kontrol penuh atas HTTP semantics, pakai Route Handler. Dan dalam aplikasi dunia nyata, Anda hampir pasti menggunakan keduanya secara bersamaan.

Tips Deployment dan Pertimbangan Runtime

Node.js vs Edge Runtime

Route Handlers mendukung dua runtime:

// Menggunakan Edge Runtime (default untuk API routes di Vercel)
export const runtime = "edge";

// Atau secara eksplisit menggunakan Node.js Runtime
export const runtime = "nodejs";

Edge Runtime cocok untuk endpoint yang butuh latensi rendah dan nggak bergantung pada Node.js API — misalnya redirect, rewrite, atau transformasi data ringan. Tapi ada batas ukurannya, yaitu 1-4 MB termasuk semua dependencies.

Node.js Runtime diperlukan saat Anda pakai modul native Node.js seperti fs, crypto, atau library yang nggak kompatibel dengan Edge. Ini juga pilihan yang lebih tepat untuk operasi CPU-intensive.

Pertimbangan untuk Serverless

Kalau Anda deploy ke Vercel atau platform serverless lainnya, ada beberapa hal yang perlu diperhatikan:

  • Cold start — setiap route handler bisa jadi serverless function terpisah. Minimalkan import yang nggak diperlukan untuk mengurangi cold start time.
  • Timeout — Vercel Free tier punya timeout 10 detik untuk serverless functions. Untuk operasi yang lebih lama, pertimbangkan Vercel Pro (60 detik) atau pakai background jobs.
  • Stateless — jangan bergantung pada state in-memory antar request. Pakai database atau cache eksternal.
  • Bundle size — Edge functions punya batas 1-4 MB. Pilih library dengan hati-hati dan manfaatkan tree-shaking.

FAQ

Apakah Route Handler bisa digunakan bersamaan dengan Server Actions dalam satu proyek?

Tentu bisa, dan justru ini pola yang direkomendasikan. Server Actions menangani mutasi internal dari UI (form submission, button click), sementara Route Handlers melayani kebutuhan endpoint HTTP publik — webhook, API untuk mobile, atau streaming. Dalam proyek skala produksi, Anda hampir pasti pakai keduanya.

Bagaimana cara menangani autentikasi di Route Handler Next.js 16?

Ada dua pendekatan utama. Pertama, pakai Higher-Order Function (HOF) wrapper yang mengecek session sebelum menjalankan handler — cocok untuk autentikasi berbasis cookie/session. Kedua, gunakan proxy.ts (pengganti middleware.ts di Next.js 16) untuk pengecekan token di level request. Untuk proxy, cukup verifikasi keberadaan cookie session saja — jangan lakukan database call atau validasi JWT yang berat di sana.

Apakah Route Handler mendukung WebSocket di Next.js?

Sayangnya, tidak secara native. Route Handlers berjalan sebagai serverless functions yang stateless dan ephemeral — koneksi WebSocket yang persistent nggak cocok dengan model ini. Alternatifnya, gunakan Server-Sent Events (SSE) dengan ReadableStream untuk komunikasi satu arah. Kalau benar-benar butuh WebSocket, pakai layanan terpisah seperti Pusher, Ably, atau Socket.io server yang di-host sendiri.

Bagaimana cara mengoptimalkan performa Route Handler untuk produksi?

Beberapa strategi yang sudah terbukti efektif: pakai "use cache" dengan cacheLife untuk GET endpoint yang datanya jarang berubah; minimalkan import untuk mengurangi cold start; gunakan Promise.all() untuk query paralel; pilih Edge Runtime untuk endpoint ringan; dan jangan lupa implementasi rate limiting (pakai Redis untuk multi-instance).

Apa perbedaan Route Handlers di App Router dengan API Routes di Pages Router?

Perbedaan utamanya: Route Handlers pakai Web API standar (Request/Response), sedangkan API Routes pakai objek Node.js (req/res). Route Handlers bisa ditempatkan di mana saja dalam direktori app/, sementara API Routes harus di pages/api/. Route Handlers juga mendukung static export, integrasi "use cache", dan typed params via RouteContext — semua fitur yang nggak ada di API Routes. Kalau Anda masih di Pages Router, migrasi ke Route Handlers sangat direkomendasikan.

Tentang Penulis Editorial Team

Our team of expert writers and editors.