An API gateway sits between your clients and your backend services. It handles the boring-but-critical stuff — routing, authentication, rate limiting, logging — so your services don’t have to. If you’ve ever wondered what Kong or AWS API Gateway actually do under the hood, building one yourself is the fastest way to find out.
This tutorial walks through a working API gateway in ~150 lines of Node.js. By the end, you’ll understand exactly what these tools abstract away, and when you actually need them versus rolling your own.
What Does an API Gateway Do?
An API gateway is essentially a reverse proxy with opinions. It receives every inbound request and decides:
- Where to route it (which backend service?)
- Whether to allow it (is the API key valid? is the client rate-limited?)
- What to add (CORS headers, request IDs, logging)
Instead of each microservice implementing auth, rate limiting, and CORS independently, the gateway handles it once at the edge. This is a core pattern in API design — keep cross-cutting concerns out of business logic.
The Stack
We’ll use Express for familiarity, but the same pattern works with Hono or Fastify. No external dependencies beyond express and http-proxy-middleware — we’ll build rate limiting and auth ourselves.
mkdir api-gateway && cd api-gateway
npm init -y
npm install express http-proxy-middleware
The Complete Gateway (~150 Lines)
Here’s the full working code. We’ll break it down section by section afterward.
// gateway.js
const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");
const app = express();
const PORT = 3000;
// --- Configuration ---
const SERVICES = {
"/api/users": { target: "http://localhost:4001", pathRewrite: { "^/api/users": "" } },
"/api/orders": { target: "http://localhost:4002", pathRewrite: { "^/api/orders": "" } },
"/api/products": { target: "http://localhost:4003", pathRewrite: { "^/api/products": "" } },
};
const API_KEYS = new Map([
["key_live_abc123", { name: "mobile-app", rateLimit: 100 }],
["key_live_def456", { name: "web-dashboard", rateLimit: 500 }],
]);
// --- Rate Limiter (in-memory, sliding window) ---
const rateLimitStore = new Map();
function checkRateLimit(clientId, maxRequests, windowMs = 60_000) {
const now = Date.now();
const record = rateLimitStore.get(clientId) || { timestamps: [] };
// Drop timestamps outside the window
record.timestamps = record.timestamps.filter((t) => now - t < windowMs);
if (record.timestamps.length >= maxRequests) {
return { allowed: false, remaining: 0, resetMs: record.timestamps[0] + windowMs - now };
}
record.timestamps.push(now);
rateLimitStore.set(clientId, record);
return { allowed: true, remaining: maxRequests - record.timestamps.length };
}
// --- Middleware: CORS ---
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-API-Key");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
// --- Middleware: Request Logging ---
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
console.log(
`${req.method} ${req.originalUrl} → ${res.statusCode} (${duration}ms) [${req.clientName || "anonymous"}]`
);
});
next();
});
// --- Middleware: API Key Authentication ---
app.use("/api", (req, res, next) => {
const apiKey = req.headers["x-api-key"];
if (!apiKey) {
return res.status(401).json({ error: "Missing X-API-Key header" });
}
const client = API_KEYS.get(apiKey);
if (!client) {
return res.status(403).json({ error: "Invalid API key" });
}
req.clientName = client.name;
req.clientRateLimit = client.rateLimit;
next();
});
// --- Middleware: Rate Limiting ---
app.use("/api", (req, res, next) => {
const { allowed, remaining, resetMs } = checkRateLimit(
req.clientName,
req.clientRateLimit
);
res.setHeader("X-RateLimit-Limit", req.clientRateLimit);
res.setHeader("X-RateLimit-Remaining", remaining);
if (!allowed) {
res.setHeader("Retry-After", Math.ceil(resetMs / 1000));
return res.status(429).json({ error: "Rate limit exceeded" });
}
next();
});
// --- Middleware: Request ID ---
app.use((req, res, next) => {
const requestId = crypto.randomUUID();
req.headers["x-request-id"] = requestId;
res.setHeader("X-Request-Id", requestId);
next();
});
// --- Proxy Routes ---
for (const [path, config] of Object.entries(SERVICES)) {
app.use(
path,
createProxyMiddleware({
target: config.target,
changeOrigin: true,
pathRewrite: config.pathRewrite,
on: {
error: (err, req, res) => {
console.error(`Proxy error for ${path}:`, err.message);
res.status(502).json({ error: "Service unavailable" });
},
},
})
);
}
// --- Health Check ---
app.get("/health", (req, res) => res.json({ status: "ok", services: Object.keys(SERVICES) }));
// --- Fallback ---
app.use((req, res) => res.status(404).json({ error: "Route not found" }));
app.listen(PORT, () => console.log(`API Gateway running on :${PORT}`));
That’s it. Let’s walk through what each piece does.
Breaking It Down
Service Routing
The SERVICES object maps URL prefixes to backend targets. A request to /api/users/123 gets proxied to http://localhost:4001/123. The pathRewrite strips the gateway prefix so your backend services don’t need to know they’re behind a gateway.
Adding a new service is one line of config. No code changes.
API Key Authentication
Every request to /api/* must include an X-API-Key header. The gateway validates it against a lookup table and attaches the client identity to the request. In production, you’d swap the Map for a database or cache lookup — the middleware shape stays the same.
This is one of several API authentication strategies. API keys work well for server-to-server and mobile app traffic. For user-facing auth, you’d add JWT validation here instead.
Rate Limiting
The rate limiter uses a sliding window algorithm — it tracks timestamps of recent requests and drops anything outside the window. Each client gets their own limit based on their API key tier.
The X-RateLimit-Remaining and Retry-After headers tell clients exactly where they stand. This is the same pattern production gateways use. For a deeper dive on the algorithms behind this, see how rate limiting actually works.
The in-memory store works for a single process. For multiple gateway instances, swap it for Redis.
CORS
The CORS middleware adds the required headers and short-circuits OPTIONS preflight requests. This is one of those things that’s trivial to implement but painful to debug when it’s missing.
Request Logging and Tracing
Every request gets a unique X-Request-Id (passed to backend services via the proxied headers) and is logged with method, path, status code, duration, and client name. This gives you basic observability without any external tooling.
Testing It
Spin up a dummy backend to verify everything works:
# In a separate terminal — fake user service on port 4001
node -e "require('http').createServer((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({ service: 'users', path: req.url }));
}).listen(4001, () => console.log('Users service on :4001'))"
Then hit the gateway:
# Missing key → 401
curl http://localhost:3000/api/users
# Valid key → proxied response
curl -H "X-API-Key: key_live_abc123" http://localhost:3000/api/users/42
# Health check (no auth required)
curl http://localhost:3000/health
When to Use This vs. Managed Gateways
This DIY gateway is ~150 lines and covers the fundamentals. But it has clear limits. Here’s how to decide:
Use a DIY gateway when:
- You have fewer than 5 services and simple routing needs
- You want to understand the concepts before adopting a framework
- You need full control over middleware ordering and behavior
- Your team already runs Node.js in production and doesn’t want another system to manage
Use Kong or similar (self-hosted) when:
- You need plugin ecosystems — OAuth2 flows, request transformation, canary releases
- You’re running dozens of services and need a declarative config format
- You want a management UI and built-in monitoring dashboards
- Multiple teams need to register and manage their own routes
Use AWS API Gateway / Cloudflare when:
- You want zero infrastructure to manage — fully serverless
- You need global edge deployment with built-in DDoS protection
- You’re already deep in that cloud ecosystem (Lambda, Workers)
- You need usage plans and billing integration for external API consumers
The honest answer: most teams should start with a managed gateway and only build custom if they hit a wall. But building one first — even as a learning exercise — means you’ll configure the managed one correctly. You’ll know why the rate limit settings exist, what path rewriting actually does, and how auth middleware chains together.
What to Add Next
If you want to extend this gateway further:
- Circuit breaking — stop proxying to a service that’s returning 500s
- Response caching — cache GET responses with a TTL
- JWT validation — verify tokens instead of (or alongside) API keys
- Load balancing — round-robin across multiple instances of a service
- Metrics — expose a
/metricsendpoint for Prometheus
Each of these is another 20-30 lines of middleware. The architecture stays the same: a chain of middleware functions that inspect, modify, or reject requests before they reach the proxy layer.
The full code from this tutorial is ready to copy and run. Start it up, point it at your services, and you’ve got a working gateway in under five minutes.