Skip to main content
Skill web app · Pelajaran 4 dari 11 · 14 menit

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

Belajar API route Next.js dari situs nyata: validasi input, rate limit per klien, satu bentuk error JSON, versioning /api/v1, dan kirim email dari server.

Oleh Matthews WongDitinjau pada

Poin utama

Route Handler adalah file route.ts yang menjawab request HTTP dengan API Web Request dan Response. Route yang aman mengerjakan lima hal berurutan: membatasi seberapa sering klien boleh memanggilnya, mem-parse dan memvalidasi input, menyimpan rahasia di server, menjawab setiap kegagalan dengan satu bentuk JSON yang konsisten, dan berada di path berversi yang bisa berubah tanpa merusak pemanggil.

Setelah pelajaran ini, Anda bisa

  • Menulis handler POST yang menolak input buruk dan banjir request sebelum mengerjakan apa pun.
  • Mengembalikan setiap error sebagai satu envelope JSON dengan code yang stabil dan status yang tepat.
  • Membuat versi API dan memensiunkan path lama dengan header Deprecation dan Sunset.
Lihat cara kerjanya

Ikuti diagramnya langkah demi langkah

Tekan Putar, atau melangkah dengan panah. Setiap langkah juga ditulis di bawah diagram.

Satu request kontak melewati route yang aman

POST ke path v1 sampai ke handler bersama, yang pertama-tama menghitung klien, memvalidasi body, memanggil layanan email dari server, dan menjawab setiap kegagalan dengan bentuk JSON yang sama.

Langkah 1 dari 7. Sebuah form mengirim POST ke path berversi. Di belakangnya ada tiga penjaga berurutan: rate limit, validasi, dan pemanggilan dari server. Salah satunya bisa mengakhiri request dengan error.

Tombol: ← → langkah · Home / End · K putar atau jeda

  1. Sebuah form mengirim POST ke path berversi. Di belakangnya ada tiga penjaga berurutan: rate limit, validasi, dan pemanggilan dari server. Salah satunya bisa mengakhiri request dengan error.

  2. File v1 hanya me-re-export handler. Path lama tanpa versi tetap berfungsi sebagai alias yang deprecated, jadi hanya ada satu implementasi di balik kedua URL.

    re-export POST from the contact route
  3. Sebelum membaca body, handler menghitung klien ini dalam jendela waktu tetap. Kuncinya gabungan nama route dan alamat klien. Pemeriksaan murah dilakukan sebelum pekerjaan mahal.

    checkRateLimit(contact + client IP, limit 5, window 60 seconds)Tebak langkah berikutnya Seorang klien mengirim request keenam dalam menit yang sama. Apa yang ia terima? (429 dengan header Retry-After · 200, tetapi email dilewati · 500, server kelebihan beban)
  4. Request keenam tidak pernah sampai ke validasi. Ia mendapat status 429, header Retry-After, dan envelope JSON yang sama dengan error lain, lengkap dengan petunjuk kapan mencoba lagi.

    rateLimited(limit, resetIn)
  5. Di bawah batas, body di-parse dan setiap field di-trim lalu dibatasi panjangnya. JSON rusak atau field yang hilang berhenti di sini dengan 400 dan petunjuk yang menyebut field-nya.

    typeof v === string ? v.trim().slice(0, max) : empty
  6. Key berasal dari variabel environment yang hanya dibaca di server, jadi browser tidak pernah memegangnya. Route memanggil layanan email dan memeriksa error yang dikembalikan SDK.

    const error = await emails.send(from, to, replyTo, template)Tebak langkah berikutnya Layanan email menolak pesan. Status apa yang dikembalikan route ini? (400, klien mengirim request yang salah · 502, panggilan upstream gagal · 200, form-nya valid)
  7. Pengiriman yang ditolak menjadi 502 upstream_error dengan pesan umum. Error asli dari provider dicatat di server dan tidak pernah ditampilkan ke pengunjung.

    console.error(provider error) return apiError(status 502)

Apa itu Route Handler, dan apa bedanya dengan page?

Route Handler adalah file route.ts di dalam direktori app yang mengekspor satu fungsi per method HTTP: GET, POST, PUT, PATCH, DELETE, HEAD, atau OPTIONS. Ia memakai API Web standar Request dan Response, plus helper NextRequest dan NextResponse yang ditambahkan Next.js. Kalau method yang tidak Anda ekspor dipanggil, Next.js menjawab 405 Method Not Allowed. route.ts tidak boleh berada di segmen yang sama dengan page.tsx, jadi API biasanya ditaruh di folder seperti api.

Baca selengkapnya (2)

Route Handler tidak di-cache secara default. Anda bisa mengaktifkan cache untuk handler GET, tetapi method lain tidak pernah di-cache, bahkan bila berada satu file dengan GET yang di-cache. Karena itu POST yang mengirim email aman ditulis: ia berjalan di setiap request. Runtime bawaannya nodejs, dan nilai edge yang lama ditandai deprecated di dokumentasi segment config.

Rahasia disimpan di variabel environment yang dibaca dari process.env di dalam handler. Secara default ia hanya ada di server. Variabel berawalan NEXT_PUBLIC_ justru kebalikannya: Next.js menyisipkan nilainya ke JavaScript yang dikirim ke browser saat build, jadi rahasia tidak boleh memakai awalan itu. Next.js juga mengharapkan file .env tidak masuk ke version control.

Bagaimana memvalidasi input di API route Next.js?

Perlakukan body sebagai teks yang tidak tepercaya. Route kontak mem-parse-nya di dalam try dan catch lalu menjawab 400 dengan code invalid_json kalau gagal, bukan membiarkan exception berubah menjadi 500. Setelah itu setiap field dipaksa ke bentuk aman: nilai yang bukan string menjadi string kosong, dan string di-trim lalu dipotong sampai panjang maksimum. Baru sesudah itu aturan diperiksa, misalnya nama minimal dua karakter, email yang cocok dengan pola sederhana, dan pesan minimal sepuluh karakter.

Baca selengkapnya (2)

Setiap kegagalan menyebut apa yang harus diperbaiki. Hint menyebut field mana, dan rentang yang harus dipenuhi, sehingga pemanggil bisa memperbaiki request tanpa membaca source. Route ini juga punya field honeypot: pengguna asli membiarkannya kosong, jadi nilai yang terisi dianggap bot dan dijawab sukses tanpa mengirim apa pun. Dengan begitu skrip tidak tahu request mana yang terdeteksi.

Validasi sengaja diletakkan setelah rate limit. Pemeriksaan yang hampir tanpa biaya, yaitu counter di memori, harus berjalan sebelum pekerjaan yang berbiaya, dan banjir request cacat sebaiknya menabrak tembok termurah lebih dulu.

Bagaimana rate limiting seharusnya bekerja, dan apa isi sebuah 429?

MDN mendefinisikan 429 Too Many Requests sebagai klien yang mengirim terlalu banyak request dalam rentang waktu tertentu, dan menyebut header Retry-After boleh memberi tahu klien berapa lama harus menunggu. Limiter situs ini berupa Map di dalam proses server: tiap kunci menyimpan hitungan dan waktu reset, request dalam jendela waktu menambah hitungan, dan ketika hitungan mencapai batas, pemanggilan mengembalikan sisa detik. Route kontak mengizinkan lima request per menit per klien.

Baca selengkapnya (2)

Limiter di memori itu jujur tetapi kecil. Komentar di filenya sendiri menyebut bahwa di produksi sebaiknya mempertimbangkan Redis untuk pembatasan terdistribusi, karena setiap proses server menyimpan Map-nya sendiri: hitungan ter-reset saat restart, dan dua instance masing-masing mengizinkan batas penuh. Ia dinding pertama yang bagus untuk satu container, dan alat yang salah untuk banyak server.

Kuncinya sama pentingnya dengan counter. Situs ini pernah memakai entri pertama X-Forwarded-For, yaitu apa pun yang dipilih klien untuk dikirim, sehingga siapa pun bisa membuat bucket baru di setiap request. Perbaikannya membaca header milik proxy sendiri lebih dulu, lalu entri forwarded yang terakhir, yaitu yang ditulis proxy situs. Rate limit yang memercayai header yang bisa dipalsukan tidak membatasi siapa pun.

Apa itu envelope error JSON yang konsisten, dan mengapa memakainya?

Envelope adalah satu bentuk yang dipakai bersama oleh semua kegagalan. Di sini bentuknya success bernilai false, error yang bisa dibaca manusia, code yang stabil dan bisa dibaca mesin, hint cara memulihkan, tautan docs, dan untuk rate limit, retryAfter dalam detik. Pemanggil lalu bercabang berdasarkan code, bukan mem-parse kalimat, sementara orang yang membaca respons tetap mendapat kalimat yang bisa ditindaklanjuti. Satu helper membangun setiap error, jadi tidak ada route yang bisa menjawab dengan bentuknya sendiri secara tidak sengaja.

Baca selengkapnya (2)

Status code membawa kategori, dan code di envelope membawa detailnya. Respons 405 wajib menyertakan header Allow berisi method yang didukung resource, seperti yang diminta MDN dari server, dan helper untuk method yang tidak didukung menambahkannya. 502 berarti server yang bertindak sebagai gateway menerima respons tidak valid dari upstream; route ini mengembalikannya ketika provider email menolak pengiriman, dengan pesan umum, sementara error asli provider hanya masuk ke log server.

Kirim Cache-Control no-store pada error, seperti helper situs ini, agar tidak disimpan, dan biarkan satu tempat mendefinisikan daftar code agar klien, test, dan deskripsi API bisa memeriksanya dengan acuan yang sama.

Bagaimana membuat versi API dan memensiunkan path lama dengan aman?

Taruh versi di path. Di sini endpoint saat ini berada di /api/v1/, dan file app/api/v1/contact/route.ts hanya me-re-export handler dari app/api/contact. Path lama tanpa versi tetap ada sebagai alias deprecated, jadi halaman yang ter-cache dan klien lama tetap berfungsi sementara yang baru pindah ke v1. Kebijakan versinya satu konstanta: dalam satu versi perubahan tetap kompatibel ke belakang, perubahan yang memecah kompatibilitas terbit sebagai /api/v2/, dan versi lama tetap berjalan selama periode yang dinyatakan.

Baca selengkapnya (1)

Beri tahu pemanggil lewat header, bukan hanya dokumentasi. RFC 9745 mendefinisikan header respons Deprecation yang nilainya berupa tanggal structured field, ditulis dengan tanda at dan detik Unix, seperti @1688169599, serta link relation bernama deprecation yang menunjuk ke dokumentasi untuk manusia. RFC 8594 mendefinisikan Sunset, yaitu HTTP-date setelah mana resource kemungkinan tidak lagi merespons. Deprecation berkata "ini sudah lama"; Sunset berkata "kapan ini hilang". Situs ini menambahkan header ke alias lewat next.config dan baru menambah Sunset setelah tanggal penghapusan ditetapkan.

Bagaimana memanggil layanan email dengan aman dari sebuah route?

Panggil dari server, bukan browser, karena pemanggilan itu butuh rahasia. Endpoint kirim Resend memakai API key bearer, dan SDK Node-nya mengembalikan objek berisi data dan error alih-alih melempar exception, jadi route memeriksa error lalu memetakannya ke respons. Template dikirim dengan memberi id dan peta variabel; Resend menolak request yang mencampur template dengan konten html, text, atau react. Mengatur reply_to ke alamat pengunjung membuat pemilik bisa membalas langsung dari inbox.

Baca selengkapnya (1)

Tiga kebiasaan menjaga route tetap tenang. Buat client secara lazy lalu pakai ulang. Periksa bahwa key ada sebelum melakukan apa pun dan jawab service_unavailable bila hilang, alih-alih crash. Dan catat error provider di server sambil hanya mengembalikan pesan umum, sehingga pengunjung tidak pernah melihat detail provider.

Cara situs ini melakukannya

Konfigurasi nyata di balik pelajaran ini

Kutipan dari repositori yang membangun dan men-deploy situs ini, apa adanya.

app/api/contact/route.ts:39-59
export async function POST(request: NextRequest) {
  const clientIp = getClientIp(request);
  const rateLimit = checkRateLimit(`contact:${clientIp}`, {
    limit: RATE_LIMIT,
    window: 60,
  });
  if (!rateLimit.success) {
    return rateLimited(RATE_LIMIT, rateLimit.resetIn);
  }

  let body: ContactBody;
  try {
    body = await request.json();
  } catch {
    return apiError({
      status: 400,
      code: "invalid_json",
      message: "Invalid JSON",
      hint: "Send a JSON object as the body, with Content-Type: application/json.",
    });
  }

Dua penjaga pertama. Limiter berjalan sebelum body dibaca, dan kuncinya adalah nama route yang digabung dengan alamat klien. Parse JSON berada di try dan catch sehingga body rusak menjadi 400 dengan code invalid_json, bukan exception yang tidak tertangani.

app/api/contact/route.ts:66-93
  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 intent = body.intent === "hr" ? "hr" : "project";
  const company = clamp(body.company, 120);
  const role = clamp(body.role, 120);
  const projectType = clamp(body.projectType, 80);
  const timeline = clamp(body.timeline, 80);
  const message = clamp(body.message, 5000);

  if (name.length < 2) {
    return apiError({
      status: 400,
      code: "invalid_request",
      message: "Please provide a valid name.",
      hint: "`name` must be 2 to 100 characters.",
    });
  }
  if (!EMAIL_RE.test(email)) {
    return apiError({
      status: 400,
      code: "invalid_request",
      message: "Please provide a valid email.",
      hint: "`email` must be a valid email address of at most 200 characters.",
    });
  }

Paksa ke bentuk aman, lalu periksa. clamp mengubah apa pun yang bukan string menjadi string kosong dan memotong sisanya sampai panjang maksimum, sehingga pemeriksaan di bawahnya hanya melihat string pendek. Setiap hint menyebut field dan rentang yang diizinkan.

app/api/contact/route.ts:137-158
  try {
    const { error } = await getResend(apiKey).emails.send({
      from: fromEmail,
      to: toEmail,
      replyTo: email,
      template: {
        id: isHiring ? TEMPLATE_HIRING : TEMPLATE_PROJECT,
        variables,
      },
    });

    if (error) {
      console.error("Resend send error:", error);
      return apiError({
        status: 502,
        code: "upstream_error",
        message: "Failed to send message.",
        hint: "The email provider rejected the message. Retry later, or email matthewswong2610@gmail.com directly.",
      });
    }

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

Pemanggilan dari server. SDK mengembalikan error alih-alih melempar exception, jadi route memeriksanya, mencatat detail provider di server, dan menjawab 502 upstream_error dengan pesan umum. Id template dan variabel menggantikan body HTML.

lib/api/errors.ts:125-139
export function rateLimited(limit: number, resetIn: number): Response {
  return apiError({
    status: 429,
    code: "rate_limited",
    message: "Too many requests. Please try again later.",
    hint: `Wait ${resetIn} seconds before retrying; the limit is ${limit} requests per minute per client.`,
    retryAfter: resetIn,
    headers: {
      "Retry-After": String(resetIn),
      "X-RateLimit-Limit": String(limit),
      "X-RateLimit-Remaining": "0",
      "X-RateLimit-Reset": String(resetIn),
    },
  });
}

Satu helper membangun 429. Retry-After membawa waktu tunggu dalam detik, header X-RateLimit menjelaskan jendela waktunya, dan retryAfter mengulanginya di body untuk klien yang hanya membaca JSON. Hint memberi tahu orang apa yang harus dilakukan.

app/api/v1/contact/route.ts:1-6
// Version 1 of the contact endpoint. The handler lives at app/api/contact,
// which stays as a deprecated, unversioned alias (see next.config.ts).
export { POST, GET, PUT, PATCH, DELETE } from "@/app/api/contact/route";

// Resend needs the Node.js runtime; segment config is never re-exported.
export const runtime = "nodejs";

Seluruh endpoint v1. Ia me-re-export setiap method dari handler asli, sehingga v1 dan alias deprecated tidak mungkin berbeda. Runtime tetap dideklarasikan sendiri; komentarnya menyebut segment config tidak pernah ikut di-re-export.

lib/api/versioning.ts:54-58
export const DEPRECATED_API_ALIASES = [
  { path: "/api/chat", successor: API_PATHS.chat },
  { path: "/api/chat/followups", successor: API_PATHS.followups },
  { path: "/api/contact", successor: API_PATHS.contact },
] as const;

Path lama sebagai data. next.config.ts membaca daftar ini untuk menambahkan header Deprecation dan Link pada tiap alias, dan panduan developer serta deskripsi OpenAPI membaca daftar yang sama, sehingga path yang deprecated cukup dideklarasikan sekali.

lib/rate-limit.ts:100-115
  // Fall back to the LAST X-Forwarded-For entry, not the first. Both Cloudflare
  // and nginx's $proxy_add_x_forwarded_for append, so the leftmost entry is
  // whatever the client chose to send: reading it let anyone mint a fresh
  // rate-limit bucket per request with `X-Forwarded-For: 1.2.3.<random>`, which
  // defeated every limit here — including the Groq spend and the Resend send
  // path. The rightmost entry is the one our own proxy wrote.
  const forwarded = request.headers.get("x-forwarded-for");
  if (forwarded) {
    const hops = forwarded
      .split(",")
      .map((hop) => hop.trim())
      .filter(Boolean);
    if (hops.length > 0) {
      return hops[hops.length - 1];
    }
  }

Alamat mana yang dianggap klien? Entri forwarded paling kiri adalah apa pun yang dikirim pemanggil, jadi kode membaca yang terakhir, yang ditulis proxy milik situs. Komentarnya mencatat bug yang digantikan oleh kode ini.

Yang sering rusak

Kesalahan yang benar-benar membuang waktu

  • Rate limit yang berkunci pada header yang bisa dipalsukan

    Berkunci pada nilai X-Forwarded-For paling kiri membuat siapa pun bisa mengirim alamat palsu berbeda di setiap request dan mendapat bucket baru setiap kali. Gunakan header yang ditetapkan edge Anda sendiri, atau entri yang ditambahkan proxy Anda.

  • Limiter di memori yang ter-reset atau terpecah

    Hitungannya hidup di satu proses. Restart mengosongkannya, dan dua instance masing-masing menegakkan batas penuh. Cukup untuk satu container; pakai penyimpanan bersama seperti Redis ketika API berjalan di beberapa instance.

  • Rahasia di balik NEXT_PUBLIC_

    Menamai key email dengan awalan itu menyisipkan nilainya ke bundle browser saat build. Padahal server sudah bisa membacanya tanpa awalan. Biarkan rahasia tanpa awalan dan baca hanya di dalam route.

  • Membocorkan error provider ke pengunjung

    Mengembalikan objek error SDK memberi tahu orang asing vendor yang Anda pakai dan apa yang ditolaknya. Catat di server lalu jawab dengan code sendiri dan pesan umum, seperti jalur 502.

  • Yang akan saya ubah di sini: penerima cadangan yang diam-diam

    Kalau variabel penerima tidak diatur, route kembali ke alamat bawaan yang ditulis di kode, bukan gagal. Pengaturan yang hilang jadi tampak berfungsi. Key diperlakukan lebih ketat: key yang hilang menjawab service_unavailable. Saya akan membuat penerima gagal dengan cara yang sama.

Code error yang dikembalikan API ini, beserta statusnya
invalid_json400Body bukan JSON yang bisa di-parse
invalid_request400Sebuah field melanggar aturannya
rate_limited429Klien melewati batas jendela waktunya
method_not_allowed405Method yang tidak diimplementasikan route
service_unavailable500Key email belum dikonfigurasi
upstream_error502Provider email menolak pengiriman
Coba sendiri

Lakukan di komputer Anda sendiri

Anda butuh proyek Next.js 15 dan curl. Buat satu route POST dengan limiter, validasi, dan helper error, lalu rusak dengan sengaja.

  1. Tulis helper error lebih dulu. Satu fungsi membangun setiap kegagalan, dengan code yang stabil dan hint.

    export function apiError(status: number, code: string, message: string, hint: string, headers: Record<string, string> = {}) {
      return Response.json(
        { success: false, error: message, code, hint },
        { status, headers: { "Cache-Control": "no-store", ...headers } },
      );
    }
  2. Tambahkan limiter kecil di memori: Map dari kunci ke hitungan dan waktu reset.

    const hits = new Map<string, { count: number; resetAt: number }>();
    export function check(key: string, limit = 5, windowMs = 60_000) {
      const now = Date.now();
      const hit = hits.get(key);
      if (!hit || now > hit.resetAt) {
        hits.set(key, { count: 1, resetAt: now + windowMs });
        return { ok: true, retryAfter: 0 };
      }
      if (hit.count >= limit) return { ok: false, retryAfter: Math.ceil((hit.resetAt - now) / 1000) };
      hit.count += 1;
      return { ok: true, retryAfter: 0 };
    }
  3. Buat app/api/v1/echo/route.ts. Batasi dulu, lalu parse di try dan catch, lalu batasi panjang dan periksa field-nya.

    export async function POST(request: Request) {
      const ip = request.headers.get("x-forwarded-for")?.split(",").at(-1)?.trim() ?? "unknown";
      const limit = check(`echo:${ip}`);
      if (!limit.ok) return apiError(429, "rate_limited", "Too many requests.", `Wait ${limit.retryAfter} seconds.`, { "Retry-After": String(limit.retryAfter) });
      let body: { name?: unknown };
      try { body = await request.json(); } catch { return apiError(400, "invalid_json", "Invalid JSON", "Send a JSON object."); }
      const name = typeof body.name === "string" ? body.name.trim().slice(0, 50) : "";
      if (name.length < 2) return apiError(400, "invalid_request", "Invalid name.", "name must be 2 to 50 characters.");
      return Response.json({ success: true, name });
    }
  4. Panggil tujuh kali berturut-turut dan baca status serta headernya. Lima pertama harus 200, yang keenam dan ketujuh 429 dengan Retry-After.

    for i in 1 2 3 4 5 6 7; do
      curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/api/v1/echo \
        -H "Content-Type: application/json" -d '{"name":"Ada"}'
    done
    curl -si -X POST localhost:3000/api/v1/echo -d 'not json' | head -n 12
  5. Ekspor juga GET dengan helper yang mengembalikan 405 dan header Allow. Lalu tambahkan folder v2 yang belum me-re-export apa pun, dan tentukan perubahan apa yang membenarkannya.

    export const GET = () =>
      apiError(405, "method_not_allowed", "This endpoint only accepts POST.", "Send POST.", { Allow: "POST" });
Kuis singkat

Tiga pertanyaan sebelum lanjut

Pilih jawaban untuk melihat kenapa benar atau salah. Tidak ada yang disimpan atau dinilai, jadi tebak saja dengan bebas.

  1. 1.Sebuah route mengizinkan 5 request per menit per klien. Seorang klien mengirim request keenam dalam menit yang sama. Apa yang seharusnya dikembalikan?
  2. 2.Mengapa berkunci pada entri pertama X-Forwarded-For untuk rate limit tidak aman?
  3. 3.Anda mengganti nama key email menjadi NEXT_PUBLIC_EMAIL_KEY agar route bisa membacanya. Apa yang sebenarnya terjadi?
Pikirkan dulu

Jawab dalam hati, lalu cek

  1. 1.Mengapa respons 405 membutuhkan header Allow?Lihat jawabannya

    Jawaban:MDN menyebut server harus menghasilkan header Allow pada respons 405, berisi method yang saat ini didukung resource. Header itu memberi tahu pemanggil apa yang seharusnya dikirim, sehingga ia bisa memperbaiki request-nya.

  2. 2.Mengapa file v1 me-re-export handler lama dan tidak menyalinnya?Lihat jawabannya

    Jawaban:Dua salinan akan menyimpang, dan alias deprecated diam-diam akan berperilaku berbeda dari v1. Re-export menjaga satu implementasi di balik kedua URL sampai alias dihapus.

  3. 3.Apa perbedaan antara header Deprecation dan Sunset?Lihat jawabannya

    Jawaban:Deprecation (RFC 9745) menyatakan sebuah resource sudah atau akan deprecated, dengan tanggal yang boleh sudah lewat. Sunset (RFC 8594) menyatakan kapan ia kemungkinan berhenti merespons, berupa HTTP-date. Yang satu mengumumkan pensiunnya, yang lain menetapkan tenggatnya.

Pelajari lebih dalam

Bahasan mendalam terkait di situs ini