Routing Without Frameworks
Match URLs and methods with plain http.createServer when a full framework adds more complexity than value.
Search across all documentation pages
Match URLs and methods with plain http.createServer when a full framework adds more complexity than value.
Quick-reference recipe card - copy-paste ready.
import { createServer } from "node:http";
type Handler = (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => void;
const routes = new Map<string, Handler>([
["GET /health", (_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
}],
["GET /users", (_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: 1 }]));
}],
]);
const server = createServer((req, res) => {
const key = `${req.method} ${new URL(req.url ?? "/", "http://localhost").pathname}`;
const handler = routes.get(key);
if (handler) return handler(req, res);
res.writeHead(404).end();
});When to reach for this: Health checks, internal admin ports, sidecars, or services with fewer than 10 endpoints where adding Express is pure dependency weight.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
type RouteHandler = (req: IncomingMessage, res: ServerResponse, params: Record<string, string>) => void;
interface Route {
method: string;
pattern: RegExp;
paramNames: string[];
handler: RouteHandler;
}
function route(method: string, path: string, handler: RouteHandler): Route {
const paramNames: string[] = [];
const pattern = new RegExp(
"^" + path.replace(/:([a-zA-Z]+)/g, (_, name) => {
paramNames.push(name);
return "([^/]+)";
}) + "$"
);
return { method, pattern, paramNames, handler };
}
const routes: Route[] = [
route("GET", "/users/:id", (req, res, params) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id: params.id }));
}),
route("DELETE", "/users/:id", (req, res, params) => {
res.writeHead(204).end();
console.log(`Deleted user ${params.id}`);
}),
];
function match(req: IncomingMessage): { handler: RouteHandler; params: Record<string, string> } | null {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
for (const r of routes) {
if (r.method !== req.method) continue;
const m = pathname.match(r.pattern);
if (!m) continue;
const params: Record<string, string> = {};
r.paramNames.forEach((name, i) => { params[name] = m[i + 1]; });
return { handler: r.handler, params };
}
return null;
}
const server = createServer((req, res) => {
const found = match(req);
if (found) return found.handler(req, res, found.params);
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
server.listen(3000);What this demonstrates:
:id to capture groups)req.url is a string path plus query; parse with URL for reliable pathname extraction| Scenario | Plain HTTP | Framework |
|---|---|---|
K8s liveness/readiness on :8081 | Yes | Overkill |
| 3-endpoint webhook receiver | Yes | Optional |
| Public REST API with auth, validation, OpenAPI | No | Yes |
| Team of 5+ on same codebase | No | Yes (conventions matter) |
// Narrow method at the boundary
const ALLOWED = new Set(["GET", "POST", "PUT", "DELETE", "PATCH"]);
if (!ALLOWED.has(req.method ?? "")) {
res.writeHead(405, { Allow: [...ALLOWED].join(", ") }).end();
return;
}req.url is /users?page=1, not /users. Parse pathname before matching. Fix: use new URL(req.url, base).pathname./users and /users/ are different paths. Fix: normalize in one middleware-style function.readJson(req) helper with size limits.| Alternative | Use When | Don't Use When |
|---|---|---|
| Express 5 | Team knows it, ecosystem plugins needed | Maximum throughput is the top constraint |
| Fastify 5 | Schema validation and speed matter | Team has zero Fastify experience and tight deadline |
| Hono | Tiny footprint, edge portability | Heavy Nest-style DI and decorators required |
| find-my-way (standalone) | Want Fastify's router without the framework | You also need middleware, parsing, and plugins |
There is no magic number. The tipping point is usually complexity: when you need middleware ordering, consistent error handling, request validation, and team conventions, a framework pays for itself around 10-15 endpoints.
Yes, but the HTTP layer does not care about your project structure. Keep route definitions in a dedicated routes.ts module either way.
Common pattern: main app on :3000, health on :8081 with a 3-line handler. Isolates probe traffic from application middleware and auth.
Extract match() and handler functions as pure units. For integration tests, use server.listen(0) to get a random port, or switch to Fastify inject().
ReDoS is a risk with complex user-supplied patterns. Use fixed route templates (like :id segments) and validate param formats in handlers.
Match /v1/users as a path prefix in your route table, or inspect an Accept-Version header. Framework routers support mount paths (/v1) more ergonomically.
Yes - parse the URL once, then branch on Upgrade header. See Real-Time Basics.
No. http.createServer is intentionally minimal. Use a framework or a focused library like find-my-way.
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 18, 2026