One Handler per Route
Routing decides which handler answers which request. The node:http module has no router of its own: it hands every request to one function, and you decide where it goes. That takes only a few lines — and writing them once shows exactly what a router does.
The pieces are the request's method (req.method), its path and its query. new URL(req.url ?? "/", "http://localhost") splits the path from the query string, because req.url is only the part after the host. A pattern like "/users/{id}" is then matched segment by segment, and the matching piece is handed to the handler as params.id. Frameworks add conveniences on top, but this covers the everyday needs.
Routes and Path Values
Two routes: the list at /users and one user at /users/{id}. The braces part is the path parameter — the piece of the URL that is data.
A route with a path parameter
The same router sends /users to one handler and /users/7 to another, and matchPath hands the 7 to the handler as params.id — the handler itself does no string cutting.
Output: TypeScript file and captured output
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: string
handler: RouteHandler
}
// Match "/users/{id}" against "/users/7" and return { id: "7" }.
// Returns undefined when the pattern does not fit the path.
function matchPath(pattern: string, path: string): Record<string, string> | undefined {
const want = pattern.split("/")
const got = path.split("/")
if (want.length !== got.length) return undefined
const params: Record<string, string> = {}
for (let i = 0; i < want.length; i++) {
if (want[i].startsWith("{") && want[i].endsWith("}")) {
params[want[i].slice(1, -1)] = got[i]
} else if (want[i] !== got[i]) {
return undefined
}
}
return params
}
function router(routes: Route[]) {
return (req: IncomingMessage, res: ServerResponse): void => {
const url = new URL(req.url ?? "/", "http://localhost")
for (const route of routes) {
const params = matchPath(route.pattern, url.pathname)
if (params !== undefined && route.method === req.method) {
route.handler(req, res, params)
return
}
}
res.writeHead(404).end("not found\n")
}
}
const routes: Route[] = [
{
method: "GET",
pattern: "/users",
handler: (req, res) => {
res.end("all users: Ana, Ben, Cleo\n")
},
},
{
method: "GET",
pattern: "/users/{id}",
handler: (req, res, params) => {
res.end(`one user, id = ${params.id}\n`)
},
},
]
async function main(): Promise<void> {
const server = createServer(router(routes))
await new Promise<void>(resolve => server.listen(0, () => resolve()))
const port = (server.address() as { port: number }).port
for (const path of ["/users", "/users/7"]) {
const res = await fetch(`http://localhost:${port}${path}`)
process.stdout.write(`GET ${path} -> ` + (await res.text()))
}
server.close()
}
main()
Query Parameters
The part after ? holds query parameters — options for the request, like a search text or a limit: /search?q=api&limit=2. They come from url.searchParams, always as strings, and get() returns null for a missing one — so ?? can supply a default.
The rule of thumb: the path says which resource, the query says how — filters, sorting, paging.
Reading the query string
The handler reads q and limit from the query. The second request skips limit, so the handler falls back to 10 — missing parameters are normal, plan for them.
Output: TypeScript file and captured output
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
function searchHandler(req: IncomingMessage, res: ServerResponse): void {
// req.url is only the path and the query, so give URL a base to work with.
const url = new URL(req.url ?? "/", "http://localhost")
// get() returns null when the parameter is missing — ?? supplies the default.
const q = url.searchParams.get("q") ?? ""
const limit = url.searchParams.get("limit") ?? "10"
res.end(`searching for ${q} with limit ${limit}\n`)
}
async function main(): Promise<void> {
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost")
if (req.method === "GET" && url.pathname === "/search") {
searchHandler(req, res)
return
}
res.writeHead(404).end("not found\n")
})
await new Promise<void>(resolve => server.listen(0, () => resolve()))
const port = (server.address() as { port: number }).port
for (const path of ["/search?q=api&limit=2", "/search?q=json"]) {
const res = await fetch(`http://localhost:${port}${path}`)
process.stdout.write(`GET ${path} -> ` + (await res.text()))
}
server.close()
}
main()