Skip to main content
BlogWeb Dev

Cara Membuat API di Next.js dengan Route Handlers (route.ts)

Panduan pemula cara membuat API di Next.js: route.ts, GET dan POST, validasi input, status code, error JSON konsisten, fetch dari client, dan form kontak.

Matthews Wong

Software Engineer

Oktober 202610 menit baca
Cara Membuat API di Next.js dengan Route Handlers (route.ts)

Ringkasan Utama

Cara membuat API di Next.js: buat file route.ts di folder app, misalnya app/api/hello/route.ts, lalu ekspor fungsi bernama sama dengan method HTTP, seperti GET atau POST. Parse body di dalam try dan catch, validasi tiap field, balas dengan status code yang tepat, dan simpan API key di environment variable tanpa awalan NEXT_PUBLIC.

Kamu sudah punya form kontak yang cantik, lalu tiba saatnya tombol Kirim benar-benar melakukan sesuatu. Kamu butuh sebuah alamat di server yang menerima data, memeriksanya, dan mengirim email. Di tahap ini banyak pemula membuat file di tempat yang salah, mendapat 405 dan 404 yang membingungkan, atau menaruh API key di kode yang ikut terkirim ke browser.

Panduan ini membangun satu API kecil dari nol dengan Route Handlers di Next.js 15 (App Router): GET, POST, validasi, error yang konsisten, pemanggilan dari client, dan endpoint form kontak. Setiap pernyataan tentang Next.js dan fetch sudah dicocokkan dengan dokumentasi resmi yang tercantum di bagian sumber, dan polanya sama dengan route kontak di situs ini.

Mau konsepnya dulu, dengan simulasi interaktif?

Panduan ini fokus pada langkah dan error. Lesson gratis kami membahas rate limit, versioning API, dan alur satu request lewat route yang aman, lengkap dengan kuis: Belajar API Route Next.js: Validasi, Rate Limit, Versioning. Kerjakan panduan ini dulu supaya kamu punya route yang berjalan, lalu lanjut ke lesson untuk membuatnya tahan banting.

Yang kamu butuhkan

Semua ini bisa dikerjakan di laptop, tanpa server dan tanpa biaya tambahan.

  • Proyek Next.js 15 dengan App Router (folder app), misalnya hasil create-next-app. Panduan ini memakai TypeScript.
  • Dev server yang berjalan dengan npm run dev, biasanya di http://localhost:3000.
  • curl di terminal untuk mencoba API tanpa harus membuat form dulu.
  • Satu file .env.local di root proyek untuk langkah terakhir.

Langkah 1: Buat route.ts pertama dan panggil dengan GET

Route Handler adalah file route.ts atau route.js di dalam folder app. Di dalamnya kamu mengekspor satu fungsi untuk tiap method HTTP yang ingin dilayani: GET, POST, PUT, PATCH, DELETE, HEAD, atau OPTIONS. Nama fungsinya harus huruf besar persis seperti itu. Response.json membuat body JSON sekaligus memasang header Content-Type menjadi application/json.

// app/api/hello/route.ts
// The function name is the HTTP method, in capital letters.
export async function GET() {
  return Response.json({ message: "Halo dari route handler" });
}

// In a second terminal, while "npm run dev" is running:
//   curl -i http://localhost:3000/api/hello

Output yang diharapkan: curl menampilkan baris status HTTP 200, header content-type application/json, dan body berisi message dengan teks Halo dari route handler.

Folder yang sama tidak boleh punya route.ts dan page.tsx sekaligus. Dokumentasi Next.js menyebut app/page.js dan app/route.js berbenturan, sedangkan app/api/route.js aman. Karena itu API hampir selalu ditaruh di subfolder seperti api.

Langkah 2: Tambahkan POST dan baca body JSON

Body request dibaca dengan method standar Web API, yaitu request.json(). Method ini melempar error kalau body kosong atau bukan JSON yang valid, jadi bungkus di try dan catch. Kalau tidak, kesalahan milik klien berubah menjadi 500 yang seolah-olah salah servermu. Balas 400 dengan code yang bisa dibaca mesin.

// app/api/contact/route.ts
export async function POST(request: Request) {
  // Wrong: const body = await request.json();
  //        an empty or broken body throws, and the visitor gets a 500.
  // Right: catch it and answer 400, because the client made the mistake.
  let body: { name?: unknown };
  try {
    body = await request.json();
  } catch {
    return Response.json(
      { success: false, code: "invalid_json" },
      { status: 400 },
    );
  }
  return Response.json({ success: true, received: body });
}

// curl -i -X POST http://localhost:3000/api/contact \
//   -H "Content-Type: application/json" \
//   -d '{"name":"Ada"}'
//
// And the broken version, to see the 400:
// curl -i -X POST http://localhost:3000/api/contact -d 'bukan json'

Output yang diharapkan: Dengan body JSON yang benar kamu mendapat status 200 dan success bernilai true. Dengan body bukan JSON kamu mendapat status 400 dan code invalid_json.

Di Pages Router dulu ada pengaturan bodyParser. Di Route Handlers kamu tidak memerlukannya. Cukup panggil request.json(), atau request.formData() kalau form-mu mengirim multipart.

Langkah 3: Validasi input sebelum dipakai

Anggap body sebagai teks yang tidak tepercaya. Ubah dulu setiap field ke bentuk yang aman, yaitu bukan string menjadi string kosong, lalu di-trim dan dipotong sampai panjang maksimum. Baru sesudah itu periksa aturannya. Urutan ini mencegah TypeError saat seseorang mengirim angka atau objek di field yang kamu kira teks.

// Wrong: trusts the shape. If name is the number 42, trim() throws -> 500.
// const name = body.name.trim();

// Right: turn anything that is not a string into "", trim, cut to a maximum.
const clamp = (v: unknown, max: number): string =>
  typeof v === "string" ? v.trim().slice(0, max) : "";

const name = clamp(body.name, 100);
const email = clamp(body.email, 200);
const message = clamp(body.message, 5000);

// Only now check the rules. The hint tells the caller what to fix.
if (name.length < 2) {
  return Response.json(
    { success: false, code: "invalid_request", hint: "name must be 2 to 100 characters." },
    { status: 400 },
  );
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
  return Response.json(
    { success: false, code: "invalid_request", hint: "email must be a valid address." },
    { status: 400 },
  );
}
if (message.length < 10) {
  return Response.json(
    { success: false, code: "invalid_request", hint: "message must be at least 10 characters." },
    { status: 400 },
  );
}

Setiap penolakan sebaiknya menyebut field mana yang salah dan rentang yang benar. Pemanggil bisa memperbaiki request tanpa membaca source-mu. Route kontak di situs ini memakai pola yang sama: clamp dulu, periksa kemudian.

Output yang diharapkan: Nama satu huruf mendapat 400 invalid_request dengan hint yang menyebut name. Email tanpa tanda at mendapat 400 dengan hint yang menyebut email.

Langkah 4: Pilih status code dan satukan bentuk error

Status code menyatakan kategori: 400 untuk request yang salah, 405 untuk method yang tidak didukung, 429 untuk terlalu banyak request, 500 untuk salah konfigurasi di server, dan 502 kalau layanan lain yang kamu panggil gagal. Satu helper kecil yang membangun semua error menjaga bentuknya sama, jadi client cukup membaca code, bukan menebak dari kalimat.

// lib/api-error.ts
export function apiError(
  status: number,
  code: string,
  message: string,
  hint: string,
): Response {
  return Response.json(
    { success: false, error: message, code, hint },
    // Response.json takes the status in its second argument.
    { status, headers: { "Cache-Control": "no-store" } },
  );
}

// Wrong: the second argument is the options object, not a number
// return Response.json({ error: "Invalid" }, 400);
// Right:
return apiError(400, "invalid_request", "Invalid name.", "name must be 2 to 100 characters.");

Perhatikan argumen kedua Response.json. Itu objek opsi yang berisi status, statusText, dan headers, bukan angka. Jadi status ditulis sebagai properti status di dalam objek itu. Cache-Control no-store pada error mencegah jawaban gagal disimpan.

Langkah 5: Baca query string dan dynamic segment

Query string dibaca lewat request.nextUrl.searchParams. Untuk URL seperti /api/items/42, buat folder bernama dengan kurung siku, app/api/items/[id]/route.ts. Mulai Next.js 15, params yang diterima fungsi adalah Promise, jadi harus di-await sebelum dipakai.

// app/api/search/route.ts  ->  GET /api/search?query=hello
import { type NextRequest } from "next/server";

export function GET(request: NextRequest) {
  const query = request.nextUrl.searchParams.get("query") ?? "";
  return Response.json({ query });
}

// app/api/items/[id]/route.ts  ->  GET /api/items/42
export async function GET(
  request: Request,
  // Wrong (Next.js 15): { params }: { params: { id: string } } then params.id
  // Right: params is a Promise, so type it that way and await it
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  return Response.json({ id });
}

Output yang diharapkan: Memanggil /api/search?query=hello mengembalikan query bernilai hello. Memanggil /api/items/42 mengembalikan id bernilai 42.

Kalau kamu menyalin contoh lama yang menulis params.id tanpa await, kode itu mengira params sudah berupa objek biasa. Di Next.js 15 itu sudah tidak benar. Tipe Promise dan await di atas adalah bentuk yang tercantum di dokumentasi sekarang.

Langkah 6: Panggil API dari client dengan fetch

Dari komponen client, kirim POST dengan header Content-Type application/json dan body hasil JSON.stringify. Jebakan terbesarnya ada di sini: fetch hanya reject kalau terjadi kegagalan level jaringan. Kalau server menjawab 400 atau 500, promise tetap berhasil dan kamu mendapat Response biasa. Periksa res.ok sendiri.

"use client";
// app/contact/ContactForm.tsx (the form's submit handler)
async function submit(data: { name: string; email: string; message: string }) {
  const res = await fetch("/api/contact", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });

  // Wrong: assume a 400 or 500 lands in a catch block. It does not.
  // fetch() only rejects on network-level failures.
  // Right: check res.ok yourself and read the error body.
  if (!res.ok) {
    const err = await res.json();
    throw new Error(err.hint ?? err.error ?? "Request failed");
  }
  return res.json();
}

Karena error dari API-mu sudah satu bentuk, client cukup menampilkan err.hint kepada pengguna. Tidak perlu if berlapis untuk setiap jenis kegagalan.

Langkah 7: Susun endpoint form kontak dengan rahasia di server

Sekarang gabungkan semuanya. API key penyedia email dibaca dari process.env di dalam route, tempat ia hanya ada di server. Periksa dulu apakah key ada, lalu parse, validasi, panggil penyedia email, dan cek hasilnya. Kalau penyedia gagal, catat detailnya di log server dan balas 502 dengan pesan umum.

# .env.local  (project root, never committed)
# Wrong: NEXT_PUBLIC_EMAIL_API_KEY=...   (inlined into the browser bundle at build time)
# Right: no prefix, so the value stays on the server
EMAIL_API_KEY=replace-with-your-key
CONTACT_TO_EMAIL=owner@example.com

// app/api/contact/route.ts  (final order of the guards)
import { apiError } from "@/lib/api-error";

export async function POST(request: Request) {
  // 1. Fail loudly when the server is not configured.
  const apiKey = process.env.EMAIL_API_KEY;
  const to = process.env.CONTACT_TO_EMAIL;
  if (!apiKey || !to) {
    return apiError(500, "service_unavailable", "Email is not configured.", "Set EMAIL_API_KEY and CONTACT_TO_EMAIL.");
  }

  // 2. Parse (400 invalid_json), 3. clamp and validate (400 invalid_request).
  //    ...the code from steps 2 and 3...

  // 4. Call the provider from the server. Check the result yourself.
  const sent = await sendEmail(apiKey, to, name, email, message);
  if (!sent.ok) {
    console.error("email provider error", sent.detail); // detail stays in the server log
    return apiError(502, "upstream_error", "Failed to send message.", "Try again later.");
  }
  return Response.json({ success: true });
}

Form yang hanya dipakai halaman Next.js milikmu sendiri kadang lebih pas memakai Server Actions. Perbandingannya ada di artikel Server Actions di production. Route Handler tetap pilihan yang benar kalau API-mu dipanggil dari luar, misalnya aplikasi mobile atau webhook.

Output yang diharapkan: Tanpa EMAIL_API_KEY route menjawab 500 service_unavailable. Dengan key dan input valid, route menjawab success bernilai true. Nilai key tidak pernah muncul di body respons maupun di bundle JavaScript browser.

Error umum dan cara memperbaikinya

Baca gejalanya, cocokkan dengan penyebab, lalu terapkan perbaikannya. Delapan kasus ini yang paling sering membuat pemula tersangkut.

GejalaPenyebabPerbaikan
POST mendapat 405 Method Not AllowedFile hanya mengekspor GET, atau nama fungsinya ditulis huruf kecil seperti post.Ekspor export async function POST dengan huruf besar semua. Next.js menjawab 405 untuk method yang tidak diekspor.
404 saat membuka /api/helloFile tidak berada di app/api/hello/route.ts, atau namanya bukan route.ts.Cocokkan path folder dengan URL dan pastikan nama file persis route.ts.
Dev server atau build mengeluh route dan page berbenturanroute.ts diletakkan satu folder dengan page.tsx.Pindahkan route.ts ke subfolder, misalnya app/api/contact/route.ts.
500 saat body kosong atau bukan JSONrequest.json() melempar error dan tidak ada try dan catch.Bungkus parse di try dan catch, balas 400 dengan code invalid_json.
500 dengan TypeError saat memanggil trimField ternyata bukan string, misalnya angka atau objek.Paksa dulu lewat clamp: bukan string menjadi string kosong, baru trim dan potong.
params.id undefined atau error di dynamic routeDi Next.js 15 params adalah Promise, tetapi dipakai seperti objek biasa.Tipekan sebagai Promise dan tulis const id dari await params.
catch di client tidak jalan padahal server menjawab 400fetch hanya reject pada kegagalan jaringan, bukan pada status error HTTP.Cek res.ok setelah fetch, lalu baca body error dan lempar sendiri.
process.env.EMAIL_API_KEY bernilai undefined, atau key terlihat di browserFile .env.local salah tempat, atau key diberi awalan NEXT_PUBLIC sehingga nilainya disisipkan ke bundle browser saat build.Taruh .env.local di root proyek, bukan di folder src. Pakai nama tanpa awalan, baca hanya di route, dan jangan commit file env.

Checklist sebelum API kamu dipakai orang lain

  • Route ada di app/api/nama-endpoint/route.ts dan tidak sejajar dengan page.tsx.
  • Nama fungsi yang diekspor huruf besar dan sesuai method yang ingin dilayani.
  • request.json() dibungkus try dan catch, dan body rusak dijawab 400.
  • Setiap field di-clamp dan divalidasi, dan hint menyebut field serta rentangnya.
  • Semua error lewat satu helper dengan code yang stabil dan status yang tepat.
  • API key dibaca dari process.env tanpa awalan NEXT_PUBLIC, dan file .env tidak masuk git.

Belajar lebih dalam

Route yang kamu buat di sini sengaja memakai pola yang sama dengan route kontak di situs ini: parse di try dan catch, field di-clamp, kegagalan penyedia email dijawab 502 dengan pesan umum, dan error dibangun satu helper. Yang belum kita bahas adalah membatasi seberapa sering satu klien boleh memanggil API, memberi versi pada path, dan memensiunkan path lama dengan header.

Lanjut ke lesson: Belajar API Route Next.js, Validasi, Rate Limit, Versioning

Lesson itu membedah kode nyata dari situs ini, termasuk satu bug rate limit yang pernah terjadi karena memercayai header yang bisa dipalsukan, dan ditutup dengan latihan yang bisa kamu jalankan di laptop.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa itu Route Handler di Next.js dan apa bedanya dengan page?

Route Handler adalah file route.ts di folder app yang menjawab request HTTP memakai API Web Request dan Response. Page merender tampilan, sedangkan Route Handler mengembalikan data seperti JSON. Keduanya tidak boleh berada di folder yang sama, jadi API biasanya ditaruh di subfolder seperti api.

Kenapa POST ke API Next.js saya error 405 Method Not Allowed?

Next.js hanya melayani method yang kamu ekspor dari route.ts, dan menjawab 405 untuk method lainnya. Pastikan kamu mengekspor fungsi bernama POST dengan huruf besar, bukan post. Pastikan juga request memang menuju route yang kamu edit.

Bagaimana cara membaca body JSON dan query parameter di Route Handler?

Baca body dengan await request.json() dan bungkus di try dan catch karena ia melempar error kalau body kosong atau tidak valid. Baca query parameter dengan request.nextUrl.searchParams.get, misalnya untuk /api/search?query=hello. Untuk data form pakai request.formData().

Kenapa params harus di-await di route Next.js 15?

Mulai Next.js 15, params di Route Handler adalah Promise. Kamu menipekannya sebagai Promise lalu menulis const id dari await params. Contoh lama yang langsung membaca params.id masih mengira perilaku yang lama.

Apakah aman memakai API key di dalam route.ts?

Aman, selama kamu membacanya dari process.env tanpa awalan NEXT_PUBLIC. Variabel tanpa awalan hanya ada di server, sedangkan yang berawalan disisipkan ke JavaScript browser saat build. Jangan lupa jaga file env tetap di luar git.