Pada aplikasi modern berbasis Next.js, kebutuhan membuat endpoint internal sering muncul lebih cepat daripada kebutuhan membangun backend terpisah. Misalnya, Anda perlu menyimpan data formulir, membaca daftar item dari database, atau memproses aksi tertentu yang membutuhkan akses ke secret server. Untuk kasus seperti ini, Route Handlers pada App Router adalah pilihan yang praktis karena API dapat hidup dekat dengan UI, namun tetap berjalan di sisi server.

Artikel ini membahas penggunaan Route Handlers Next.js 16 sebagai API internal: kapan cocok dipakai, kapan sebaiknya memilih backend terpisah, cara membaca request, mengembalikan JSON, menangani error, memvalidasi input, membatasi method, membuat endpoint CRUD sederhana, dan mengintegrasikannya dengan halaman server maupun client. Di bagian akhir, kita juga bahas aspek keamanan dasar agar logic sensitif tidak bocor ke bundle client.

Apa Itu Route Handlers di App Router

Di App Router, Anda bisa menambahkan file route.ts atau route.js di dalam folder app untuk membuat endpoint HTTP. Setiap file bisa mengekspor fungsi berdasarkan method HTTP seperti GET, POST, PUT, PATCH, dan DELETE.

Contohnya, file app/api/todos/route.ts akan melayani request ke /api/todos. File app/api/todos/[id]/route.ts akan melayani request ke /api/todos/:id.

Keuntungan utamanya:

  • Ko-lokasi: endpoint berada di dalam project yang sama dengan UI.
  • Akses server-only: bisa membaca environment variable dan berkomunikasi langsung ke database atau service internal.
  • Cocok untuk API internal: terutama jika konsumen utamanya adalah aplikasi Anda sendiri, bukan banyak client eksternal.

Kapan Memakai Route Handlers, Kapan Backend Terpisah

Pilih Route Handlers jika

  • API terutama dipakai oleh aplikasi Next.js yang sama.
  • Logika backend masih relatif sederhana sampai menengah.
  • Tim ingin deployment lebih sederhana dalam satu codebase.
  • Anda butuh endpoint untuk form submission, dashboard internal, webhook ringan, atau operasi CRUD dasar.

Pertimbangkan backend terpisah jika

  • API akan dipakai oleh banyak aplikasi berbeda, misalnya web, mobile, partner, dan public API.
  • Domain bisnis backend sudah kompleks dan perlu lifecycle terpisah.
  • Anda butuh kontrol independen untuk scaling, release, observability, atau security boundary yang lebih ketat.
  • Tim frontend dan backend bekerja sangat terpisah.

Trade-off pentingnya adalah kesederhanaan versus pemisahan concern. Route Handlers mempercepat pengembangan dan mengurangi overhead, tetapi jika aplikasi berkembang menjadi platform multi-konsumen, backend terpisah sering lebih mudah dikelola dalam jangka panjang.

Struktur Dasar Endpoint Internal

Misalkan kita ingin membuat API internal untuk resource todo. Struktur folder sederhananya:

app/
  api/
    todos/
      route.ts
      [id]/
        route.ts

Berikut contoh endpoint GET dan POST pada app/api/todos/route.ts:

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

const todos: Todo[] = [
  { id: '1', title: 'Belajar Route Handler', completed: false },
];

export async function GET() {
  return Response.json({ data: todos }, { status: 200 });
}

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const title = typeof body.title === 'string' ? body.title.trim() : '';

    if (!title) {
      return Response.json(
        { error: 'title wajib diisi' },
        { status: 400 }
      );
    }

    const newTodo: Todo = {
      id: crypto.randomUUID(),
      title,
      completed: false,
    };

    todos.push(newTodo);

    return Response.json({ data: newTodo }, { status: 201 });
  } catch {
    return Response.json(
      { error: 'Payload JSON tidak valid' },
      { status: 400 }
    );
  }
}

Contoh di atas memakai array in-memory agar fokus pada bentuk Route Handler. Dalam aplikasi nyata, Anda biasanya akan mengganti penyimpanan data dengan database atau service internal.

Membaca request

Objek request mengikuti Web Request API. Hal yang paling sering dipakai:

  • await request.json() untuk body JSON.
  • new URL(request.url) untuk membaca query string.
  • request.headers.get('authorization') untuk header tertentu.
  • request.formData() jika menerima multipart/form-data sederhana.

Contoh membaca query parameter:

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const completed = searchParams.get('completed');

  const filtered =
    completed === null
      ? todos
      : todos.filter((todo) => String(todo.completed) === completed);

  return Response.json({ data: filtered });
}

CRUD Sederhana dengan Route Handler Dinamis

Sekarang kita tambahkan endpoint untuk item tunggal pada app/api/todos/[id]/route.ts:

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

const todos: Todo[] = [
  { id: '1', title: 'Belajar Route Handler', completed: false },
];

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

export async function GET(_request: Request, context: Context) {
  const { id } = await context.params;
  const todo = todos.find((item) => item.id === id);

  if (!todo) {
    return Response.json({ error: 'Todo tidak ditemukan' }, { status: 404 });
  }

  return Response.json({ data: todo }, { status: 200 });
}

export async function PUT(request: Request, context: Context) {
  const { id } = await context.params;

  try {
    const body = await request.json();
    const title = typeof body.title === 'string' ? body.title.trim() : '';
    const completed = typeof body.completed === 'boolean' ? body.completed : undefined;

    const index = todos.findIndex((item) => item.id === id);
    if (index === -1) {
      return Response.json({ error: 'Todo tidak ditemukan' }, { status: 404 });
    }

    if (!title) {
      return Response.json({ error: 'title wajib diisi' }, { status: 400 });
    }

    todos[index] = {
      ...todos[index],
      title,
      completed: completed ?? todos[index].completed,
    };

    return Response.json({ data: todos[index] }, { status: 200 });
  } catch {
    return Response.json({ error: 'Payload JSON tidak valid' }, { status: 400 });
  }
}

export async function DELETE(_request: Request, context: Context) {
  const { id } = await context.params;
  const index = todos.findIndex((item) => item.id === id);

  if (index === -1) {
    return Response.json({ error: 'Todo tidak ditemukan' }, { status: 404 });
  }

  const deleted = todos.splice(index, 1)[0];
  return Response.json({ data: deleted }, { status: 200 });
}

Beberapa catatan penting:

  • GET untuk membaca satu item.
  • PUT untuk mengganti atau memperbarui data secara penuh/terstruktur.
  • DELETE untuk menghapus item.

Jika Anda hanya mengekspor method tertentu, method lain secara otomatis tidak tersedia pada route tersebut. Namun tetap baik untuk mendesain API secara eksplisit dan konsisten agar klien tidak bingung.

Validasi Input dan Penanganan Error yang Rapi

Kesalahan umum saat membuat API internal adalah terlalu percaya pada input dari client. Meskipun request berasal dari UI milik Anda sendiri, data tetap harus divalidasi di server. Alasannya sederhana: request bisa dipalsukan, UI bisa berubah, dan bug client tidak boleh merusak data.

Pola validasi minimum

  • Periksa tipe data, misalnya string, boolean, number.
  • Normalisasi input, misalnya trim() pada string.
  • Tolak field wajib yang kosong.
  • Batasi panjang input jika perlu.
  • Jangan langsung melempar error mentah ke client.

Contoh helper validasi sederhana:

function validateCreateTodo(input: unknown) {
  if (!input || typeof input !== 'object') {
    return { ok: false, error: 'Body harus berupa object' };
  }

  const title = typeof (input as { title?: unknown }).title === 'string'
    ? (input as { title: string }).title.trim()
    : '';

  if (!title) {
    return { ok: false, error: 'title wajib diisi' };
  }

  if (title.length > 120) {
    return { ok: false, error: 'title terlalu panjang' };
  }

  return { ok: true, data: { title } };
}

Dengan pola ini, handler Anda menjadi lebih rapi dan mudah dites. Untuk aplikasi yang lebih besar, Anda bisa mempertimbangkan library validasi skema agar aturan input lebih konsisten, tetapi prinsip dasarnya tetap sama: validasi di server, bukan hanya di client.

Error handling

Bedakan beberapa jenis error berikut:

  • 400 Bad Request: format input salah atau field wajib tidak valid.
  • 401 Unauthorized: user belum terautentikasi.
  • 403 Forbidden: user terautentikasi tetapi tidak punya izin.
  • 404 Not Found: resource tidak ada.
  • 500 Internal Server Error: error tak terduga di server.

Untuk error internal, jangan kirim stack trace ke client. Log detail error di server, lalu kirim pesan generik ke client.

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const result = validateCreateTodo(body);

    if (!result.ok) {
      return Response.json({ error: result.error }, { status: 400 });
    }

    // simpan ke database di sini
    return Response.json({ data: result.data }, { status: 201 });
  } catch (error) {
    console.error('POST /api/todos gagal:', error);
    return Response.json(
      { error: 'Terjadi kesalahan pada server' },
      { status: 500 }
    );
  }
}

Membatasi Method dan Menjaga Kontrak API

Di Route Handlers, Anda cukup mengekspor method yang memang diizinkan. Jika route hanya punya GET dan POST, jangan tambahkan method lain tanpa alasan. Ini membantu menjaga kontrak API tetap jelas.

Praktik yang baik:

  • Gunakan nama route yang konsisten, misalnya /api/todos dan /api/todos/:id.
  • Pisahkan route collection dan item tunggal.
  • Gunakan status code yang tepat.
  • Kembalikan bentuk respons yang konsisten, misalnya selalu memakai { data } atau { error }.

Jangan mencampur terlalu banyak logika bisnis besar langsung di dalam file route.ts. Lebih baik pindahkan ke modul service atau domain agar handler tetap tipis dan mudah dibaca.

Integrasi dengan Server Component dan Client Component

Dari Server Component

Server Component bisa memanggil Route Handler dengan fetch. Ini berguna jika Anda ingin satu lapisan API internal dipakai ulang oleh beberapa halaman atau komponen.

export default async function TodoPage() {
  const response = await fetch('http://localhost:3000/api/todos', {
    cache: 'no-store',
  });

  if (!response.ok) {
    throw new Error('Gagal mengambil todo');
  }

  const result = await response.json();

  return (
    <ul>
      {result.data.map((todo: { id: string; title: string }) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Namun ada trade-off. Jika data hanya dibutuhkan di server dan sumber datanya bisa dipanggil langsung, sering kali lebih efisien memanggil database atau service langsung dari Server Component tanpa lewat HTTP internal. Route Handler lebih cocok jika Anda memang ingin antarmuka API yang konsisten atau perlu dipakai juga oleh client.

Dari Client Component

Untuk interaksi browser seperti submit form, memanggil Route Handler dari client adalah pola yang natural.

'use client';

import { useState } from 'react';

export function TodoForm() {
  const [title, setTitle] = useState('');
  const [error, setError] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError('');

    const response = await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title }),
    });

    const result = await response.json();

    if (!response.ok) {
      setError(result.error || 'Gagal menyimpan data');
      return;
    }

    setTitle('');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button type="submit">Tambah</button>
      {error ? <p>{error}</p> : null}
    </form>
  );
}

Poin pentingnya: client hanya tahu endpoint API, bukan secret, query database, atau logic sensitif di baliknya.

Keamanan Dasar: Secret Tetap di Server, Logic Sensitif Jangan Bocor

Salah satu alasan utama memakai Route Handlers adalah menjaga operasi sensitif tetap berjalan di server. Beberapa aturan dasarnya:

1. Jangan kirim secret ke client

Environment variable yang berisi API key, token privat, atau kredensial database harus dibaca di server, misalnya di Route Handler atau modul server-only. Jangan pernah mengirim nilainya ke komponen client atau response API kecuali memang diperlukan dan aman.

2. Jangan letakkan logic sensitif di Client Component

Jika sebuah keputusan bisnis penting, pengecekan permission, perhitungan harga final, atau akses service privat diletakkan di client, logikanya akan ikut masuk ke bundle browser dan bisa dianalisis pengguna. Simpan logic seperti itu di server, lalu expose hanya hasil yang aman melalui Route Handler.

3. Validasi otorisasi di server

Jangan mengandalkan UI untuk membatasi akses. Tombol yang disembunyikan di client bukan kontrol keamanan. Pastikan Route Handler melakukan pengecekan autentikasi dan otorisasi sebelum menjalankan operasi yang sensitif.

4. Batasi data yang dikembalikan

Jangan mengembalikan seluruh record database jika client hanya butuh sebagian field. Praktik ini mengurangi risiko kebocoran data dan membuat payload lebih kecil.

5. Hati-hati saat logging

Log server sangat berguna untuk debugging, tetapi jangan log password, token, atau data pribadi secara mentah. Redaksi atau buang field sensitif sebelum ditulis ke log.

Kesalahan Umum dan Tips Debugging

Kesalahan umum

  • Terlalu banyak logic di route handler: file menjadi sulit dirawat. Pindahkan ke service/helper.
  • Tidak menangani JSON invalid: request.json() bisa gagal dan harus dibungkus try/catch.
  • Tidak konsisten status code: client jadi sulit menafsirkan error.
  • Mempercayai validasi client: celah bug dan keamanan lebih besar.
  • Menggunakan API internal untuk semua hal: kadang lebih baik panggil data source langsung dari server daripada membuat hop HTTP tambahan.

Tips debugging

  • Cek Network tab di browser untuk status code, payload request, dan respons.
  • Tambahkan log server yang ringkas pada titik penting, misalnya sebelum dan sesudah operasi database.
  • Uji endpoint dengan curl atau REST client untuk memastikan masalah bukan berasal dari UI.
curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Menulis dokumentasi"}'

Jika endpoint bekerja lewat curl tetapi gagal di browser, kemungkinan masalah ada pada header, payload, state UI, atau penanganan response di client.

Penutup

Route Handlers di Next.js App Router adalah solusi yang sangat cocok untuk API internal yang aman dan rapi. Anda bisa membaca request dengan API web standar, mengembalikan JSON secara konsisten, membatasi method dengan jelas, memvalidasi input di server, dan menjaga secret tetap tersembunyi. Untuk aplikasi yang kebutuhan backend-nya masih berada dalam satu domain produk, pendekatan ini sering memberi keseimbangan terbaik antara kecepatan pengembangan dan struktur yang tetap sehat.

Namun, gunakan dengan sadar. Jika API Anda mulai menjadi platform terpisah yang dipakai banyak konsumen, atau logika domain backend sudah terlalu besar, pertimbangkan memisahkan backend agar arsitektur tetap bersih. Intinya bukan memilih satu pendekatan untuk semua situasi, melainkan memilih batas yang tepat untuk skala aplikasi Anda saat ini.