Routing Without Frameworks
Match URLs and methods with plain http.createServer when a full framework adds more complexity than value.
Recipe
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.
Working Example
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:
- Path parameters via regex conversion (
:idto capture groups) - Method-aware routing without a router library
- Early return pattern to avoid double responses
- A router table you can unit-test independently of HTTP
Deep Dive
How It Works
- Node passes every request to a single callback - routing is your responsibility
req.urlis a string path plus query; parse withURLfor reliable pathname extraction- Route matching is O(n) over a linear list - fine for small route tables, slow at hundreds of routes
- Frameworks add trie-based routers, middleware chains, and param coercion
When Plain Routing Wins
| 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) |
TypeScript Notes
// 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;
}Gotchas
- Query strings in route keys -
req.urlis/users?page=1, not/users. Parse pathname before matching. Fix: usenew URL(req.url, base).pathname. - Trailing slashes -
/usersand/users/are different paths. Fix: normalize in one middleware-style function. - No automatic 405 - Unmatched method on an existing path returns 404 unless you check method separately. Fix: two-pass match: path first, then method.
- Async errors uncaught - Raw handlers do not catch promise rejections. Fix: wrap in try/catch or use Express 5 / Fastify.
- Body parsing duplicated - Every POST route needs its own parser. Fix: extract a
readJson(req)helper with size limits. - No built-in CORS - Browsers will block cross-origin calls. Fix: add an OPTIONS handler or use framework middleware.
Alternatives
| 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 |
FAQs
How many routes before I should use a framework?
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.
Can I use node:http with TypeScript path aliases?
Yes, but the HTTP layer does not care about your project structure. Keep route definitions in a dedicated routes.ts module either way.
Should health checks share the main server?
Common pattern: main app on :3000, health on :8081 with a 3-line handler. Isolates probe traffic from application middleware and auth.
How do I test routes without starting a port?
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().
Is regex routing safe?
ReDoS is a risk with complex user-supplied patterns. Use fixed route templates (like :id segments) and validate param formats in handlers.
What about REST versioning in plain HTTP?
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.
Can I share routing logic between HTTP and WebSocket?
Yes - parse the URL once, then branch on Upgrade header. See Real-Time Basics.
Does Node 24 have a built-in router?
No. http.createServer is intentionally minimal. Use a framework or a focused library like find-my-way.
Related
- HTTP Basics in Node - request/response lifecycle
- Middleware Pattern - composable pipelines
- Express Basics - when to upgrade
- Hono Basics - minimal framework alternative
- Framework Selection Checklist - pick the right tool
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.