Kenapa ini penting
Backend jarang berdiri sendiri. Ia memanggil payment gateway, layanan kirim email, API ongkos kirim, API cuaca, atau layanan internal lain. Di sisi lain, backend juga dipanggil oleh frontend React yang berjalan di domain atau port berbeda, dan di sinilah hampir semua pemula pertama kali bertemu error merah di console browser: CORS.
Modul ini menutup lingkaran: server memanggil API lain dengan aman (dengan timeout dan penanganan error), lalu frontend React yang sudah kamu kuasai disambungkan ke Tugas API yang baru saja kamu buat.
Konsep inti
fetch di server
Node sudah punya fetch global yang sama dengan di browser (stabil sejak Node 21, ada sejak 18). Tidak perlu axios atau node-fetch untuk kebutuhan dasar. Ada tiga hal yang harus diperhatikan di server:
fetchtidak melempar error untuk status 4xx/5xx. Promise hanya reject kalau jaringan gagal (DNS, koneksi ditolak). Kamu harus memeriksares.oksendiri.- Selalu pasang timeout. Tanpa timeout, kalau API luar menggantung, request penggunamu ikut menggantung, dan koneksi menumpuk. Gunakan
signal: AbortSignal.timeout(ms). Saat waktu habis, fetch reject dengan error bernamaTimeoutError. - Terjemahkan error API luar ke status yang tepat untuk client-mu. API luar membalas 404 atau 500? Itu bukan salah client-mu; balas 502 Bad Gateway. API luar terlalu lama? Balas 504 Gateway Timeout.
Analoginya: kamu memesan bahan ke pemasok. Kamu tetap harus memeriksa isi kiriman (status), dan kalau pemasok tidak datang dalam 30 menit, kamu tidak menunggu sampai warung tutup; kamu bilang ke pelanggan bahwa menunya sedang tidak tersedia.
API key untuk layanan pihak ketiga disimpan di environment variable dan hanya dipakai di server. Inilah salah satu alasan utama punya backend: frontend tidak pernah melihat rahasianya.
Apa itu CORS
Browser menerapkan Same-Origin Policy: JavaScript di halaman http://localhost:5173 (Vite) tidak boleh membaca response dari origin lain seperti http://localhost:3000, kecuali server tujuan mengizinkan. Origin adalah gabungan protokol + domain + port; beda port saja sudah dianggap beda origin.
CORS (Cross-Origin Resource Sharing) adalah cara server memberi izin lewat header response, terutama Access-Control-Allow-Origin. Untuk request "tidak sederhana" (misalnya method PATCH/DELETE atau header Content-Type: application/json), browser lebih dulu mengirim request preflight dengan method OPTIONS untuk bertanya "boleh tidak?". Server harus menjawab dengan header Access-Control-Allow-Methods dan Access-Control-Allow-Headers yang sesuai.
Hal penting yang sering disalahpahami: CORS bukan pengaman API. CORS hanya mengatur apakah browser boleh memberikan response ke JavaScript halaman lain. curl, Postman, atau server lain tetap bisa memanggil API-mu dan membaca hasilnya. Perlindungan sesungguhnya adalah autentikasi dan otorisasi (bagian 2).
Paket cors
Paket cors (2.8.6) adalah middleware yang mengurus header dan preflight:
import cors from "cors";
app.use(cors({ origin: ["http://localhost:5173", "https://tugasku.id"] }));
Opsi origin bisa berupa string, array, regex, atau fungsi. Dengan array, header Access-Control-Allow-Origin hanya dikirim kalau origin request ada di daftar. Hindari origin: "*" untuk API yang memakai cookie/kredensial; kombinasi itu memang ditolak browser.
Alternatif saat development: proxy Vite
Saat development, kamu juga bisa menghindari CORS dengan proxy dari dev server Vite (server.proxy di vite.config.js), sehingga browser mengira API berada di origin yang sama. Tapi di produksi, kalau frontend dan API ada di domain berbeda, pengaturan CORS di server tetap dibutuhkan.
Contoh kode
Memanggil API luar dengan timeout dan penerjemahan error:
// fetch-demo.js, jalankan: node fetch-demo.js
import express from "express";
const app = express();
// Endpoint "lambat" untuk simulasi API pihak ketiga yang lemot
app.get("/lambat", async (req, res) => {
await new Promise((r) => setTimeout(r, 3000));
res.json({ pesan: "akhirnya..." });
});
async function ambilJSON(url, { timeout = 2000 } = {}) {
const res = await fetch(url, {
headers: { Accept: "application/json", "User-Agent": "kodingup-camp-demo" },
signal: AbortSignal.timeout(timeout), // batalkan kalau lewat batas waktu
});
if (!res.ok) {
// fetch TIDAK melempar untuk 4xx/5xx, kita yang harus memeriksa
const err = new Error(`API luar membalas ${res.status}`);
err.status = 502; // Bad Gateway: masalah ada di layanan hulu
throw err;
}
return res.json();
}
app.get("/api/pengguna/:id", async (req, res) => {
const user = await ambilJSON(`https://jsonplaceholder.typicode.com/users/${req.params.id}`);
// kirim hanya field yang dibutuhkan frontend
res.json({ id: user.id, nama: user.name, kota: user.address.city });
});
app.get("/api/coba-lambat", async (req, res) => {
const data = await ambilJSON(`http://localhost:${PORT}/lambat`, { timeout: 1000 });
res.json(data);
});
app.use((err, req, res, next) => {
if (err.name === "TimeoutError") {
return res.status(504).json({ error: "API luar terlalu lama merespons" });
}
console.error(err.message);
res.status(err.status ?? 500).json({ error: err.message });
});
const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => console.log(`Jalan di http://localhost:${PORT}`));
curl -s -w ' [%{http_code}]' localhost:3000/api/pengguna/1
# → {"id":1,"nama":"Leanne Graham","kota":"Gwenborough"} [200]
curl -s -w ' [%{http_code}]' localhost:3000/api/pengguna/999
# → {"error":"API luar membalas 404"} [502]
time curl -s -w ' [%{http_code}]' localhost:3000/api/coba-lambat
# → {"error":"API luar terlalu lama merespons"} [504]
# → ... 1.011 total ← berhenti di 1 detik, tidak menunggu 3 detik
Kalau server tujuan mati sama sekali, fetch reject dengan TypeError: fetch failed dan detailnya ada di err.cause.
CORS dengan daftar origin, diuji dengan curl yang meniru header browser:
// cors-demo.js
import express from "express";
import cors from "cors";
const app = express();
app.use(cors({ origin: ["http://localhost:5173", "https://tugasku.id"] }));
app.get("/api/x", (req, res) => res.json({ ok: true }));
app.listen(3000);
curl -s -i localhost:3000/api/x -H "Origin: http://localhost:5173" | grep -iE "^(HTTP|access-control|vary)"
# → HTTP/1.1 200 OK
# → Access-Control-Allow-Origin: http://localhost:5173
# → Vary: Origin
curl -s -i localhost:3000/api/x -H "Origin: http://evil.example" | grep -iE "^(HTTP|access-control|vary)"
# → HTTP/1.1 200 OK ← server tetap menjawab!
# → Vary: Origin ← tapi tanpa Allow-Origin, browser menolak memberikan hasilnya ke JS
# Preflight yang dikirim browser sebelum DELETE dengan JSON
curl -s -i -X OPTIONS localhost:3000/api/x \
-H "Origin: https://tugasku.id" \
-H "Access-Control-Request-Method: DELETE" \
-H "Access-Control-Request-Headers: content-type" | grep -iE "^(HTTP|access-control)"
# → HTTP/1.1 204 No Content
# → Access-Control-Allow-Origin: https://tugasku.id
# → Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
# → Access-Control-Allow-Headers: content-type
Contoh kedua membuktikan poin penting: origin yang tidak diizinkan tetap mendapat 200 OK dan datanya. CORS hanya membuat browser menahan response itu dari JavaScript halaman lain.
Menghubungkan frontend React (proyek Vite di port 5173) ke Tugas API. Jalankan API dengan CORS_ORIGIN=http://localhost:5173 npm run dev (port 3000).
// src/api.js (proyek React)
const BASE = import.meta.env.VITE_API_URL ?? "http://localhost:3000/api/v1";
async function request(path, options = {}) {
const res = await fetch(`${BASE}${path}`, {
...options,
headers: { "Content-Type": "application/json", ...options.headers },
});
if (res.status === 204) return null;
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.error.message), body.error); // bawa code & details
return body;
}
export const api = {
daftar: (q = "") => request(`/tugas?limit=50&q=${encodeURIComponent(q)}`),
tambah: (judul) => request("/tugas", { method: "POST", body: JSON.stringify({ judul }) }),
toggle: (t) => request(`/tugas/${t.id}`, { method: "PATCH", body: JSON.stringify({ selesai: !t.selesai }) }),
hapus: (id) => request(`/tugas/${id}`, { method: "DELETE" }),
};
// src/DaftarTugas.jsx
import { useEffect, useState } from "react";
import { api } from "./api.js";
export default function DaftarTugas() {
const [tugas, setTugas] = useState([]);
const [judul, setJudul] = useState("");
const [error, setError] = useState(null);
async function muat() {
const { data } = await api.daftar();
setTugas(data);
}
useEffect(() => {
muat().catch((e) => setError(e.message));
}, []);
async function kirim(e) {
e.preventDefault();
try {
await api.tambah(judul);
setJudul("");
setError(null);
await muat();
} catch (e) {
// pesan per field dari server, misalnya "Judul minimal 3 karakter"
setError(e.details?.fieldErrors?.judul?.[0] ?? e.message);
}
}
return (
<main>
<form onSubmit={kirim}>
<input value={judul} onChange={(e) => setJudul(e.target.value)} placeholder="Tugas baru" />
<button>Tambah</button>
</form>
{error && <p role="alert">{error}</p>}
<ul>
{tugas.map((t) => (
<li key={t.id}>
<label>
<input type="checkbox" checked={t.selesai} onChange={() => api.toggle(t).then(muat)} />
{t.judul} ({t.prioritas})
</label>
<button onClick={() => api.hapus(t.id).then(muat)}>Hapus</button>
</li>
))}
</ul>
</main>
);
}
Kalau di console browser muncul pesan seperti "blocked by CORS policy: No 'Access-Control-Allow-Origin' header", periksa: apakah CORS_ORIGIN sama persis dengan origin frontend (termasuk port, tanpa garis miring di akhir), dan apakah middleware cors dipasang sebelum router.
Kesalahan umum
- Mengira
fetchmelempar error untuk 404/500. Tidak. Periksares.okataures.statussendiri. - Memanggil API luar tanpa timeout. Satu layanan lambat bisa membuat seluruh server-mu ikut lambat. Selalu pakai
AbortSignal.timeout. - Menaruh API key pihak ketiga di frontend (misalnya di
VITE_...). Semua variabelVITE_ikut ke bundle dan bisa dibaca siapa saja. Panggil API itu dari server. - Memakai
cors()tanpa opsi di produksi. Default-nya mengizinkan semua origin (*). Tentukan daftar origin yang jelas. - Menganggap CORS sebagai keamanan. CORS hanya berlaku di browser;
curldan server lain tidak terpengaruh. Lindungi data dengan autentikasi.
Latihan
- Mudah, Tambahkan endpoint
GET /api/todo-acakyang mengambilhttps://jsonplaceholder.typicode.com/todos/<angka acak 1-200>dengan timeout 2 detik, lalu mengembalikan hanya{ judul, selesai }. - Sedang, Buat fungsi
ambilDenganRetry(url, { percobaan = 3, timeout = 1000 })yang mencoba ulang kalau terjadiTimeoutErroratau status 5xx, dengan jeda 200 ms, 400 ms, 800 ms (exponential backoff). Error 4xx tidak di-retry. - Menantang, Tambahkan cache sederhana di memori untuk
GET /api/pengguna/:id: hasil disimpan 60 detik, dan response menyertakan headerX-Cache: HITatauX-Cache: MISS. Uji dua kali dengancurl -i.
Lihat solusi
1.
app.get("/api/todo-acak", async (req, res) => {
const id = Math.floor(Math.random() * 200) + 1;
const todo = await ambilJSON(`https://jsonplaceholder.typicode.com/todos/${id}`, { timeout: 2000 });
res.json({ judul: todo.title, selesai: todo.completed });
});
2.
const tidur = (ms) => new Promise((r) => setTimeout(r, ms));
export async function ambilDenganRetry(url, { percobaan = 3, timeout = 1000 } = {}) {
for (let ke = 1; ; ke++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(timeout) });
if (res.status >= 500) throw Object.assign(new Error(`Server hulu ${res.status}`), { bisaUlang: true });
if (!res.ok) throw new Error(`Request ditolak ${res.status}`); // 4xx: jangan diulang
return await res.json();
} catch (err) {
const bisaUlang = err.name === "TimeoutError" || err.bisaUlang;
if (!bisaUlang || ke >= percobaan) throw err;
await tidur(200 * 2 ** (ke - 1)); // 200, 400, 800 ms
}
}
}
3.
const cache = new Map(); // id → { data, kedaluwarsa }
app.get("/api/pengguna/:id", async (req, res) => {
const simpanan = cache.get(req.params.id);
if (simpanan && simpanan.kedaluwarsa > Date.now()) {
res.set("X-Cache", "HIT");
return res.json(simpanan.data);
}
const user = await ambilJSON(`https://jsonplaceholder.typicode.com/users/${req.params.id}`);
const data = { id: user.id, nama: user.name, kota: user.address.city };
cache.set(req.params.id, { data, kedaluwarsa: Date.now() + 60_000 });
res.set("X-Cache", "MISS").json(data);
});
// curl -si localhost:3000/api/pengguna/1 | grep -i x-cache → X-Cache: MISS
// curl -si localhost:3000/api/pengguna/1 | grep -i x-cache → X-Cache: HIT
Ringkasan
- Node punya
fetchglobal; periksares.oksendiri karena 4xx/5xx tidak membuat Promise reject. - Selalu pakai
signal: AbortSignal.timeout(ms); tanganiTimeoutErrordengan 504 dan kegagalan layanan hulu dengan 502. - Simpan API key pihak ketiga di env server; backend menjadi perantara aman bagi frontend.
- CORS adalah izin dari server agar browser boleh memberikan response lintas origin; preflight
OPTIONSmendahului request non-sederhana. - Gunakan paket
corsdengan daftar origin yang jelas; CORS bukan pengganti autentikasi. - Frontend React memanggil API lewat satu modul
api.jsyang membaca format error{ code, message, details }secara konsisten.