Routing Patterns — URL Routing in Backend Services
Status: Active | Last Updated: 2026-08-29 Category: Backend — Routing Prerequisites: HTTP basics Tags: routing, url, methods, route-groups, params Estimated Time: 3-4 hours (Self-paced, includes lab time)
Summary
Routing maps an incoming request to a handler. Static paths (/about) are easy. Parameterized paths (/users/:id) need parsing. Wildcards, query strings, and method matching all need to be considered. This article covers the patterns used in modern backends, from simple if/else chains to structured route groups.
What You'll Learn (Core Competencies)
- Distinguish between static, parameterized, and wildcard routes and when to use each
- Implement method-based routing (GET, POST, PUT, DELETE) correctly
- Parse query strings and path parameters using the Web URL API
- Design route groups and versioned API prefixes
- Return
404for unmatched paths and405for unsupported methods
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
1. Architectural Overview & Core Schema
Routing is the interface between clients and business logic. A well-designed route structure communicates intent: /v1/users/42 tells a developer this is a versioned user resource, while /users/42/posts shows a nested relationship. Poor routing leads to ambiguous URLs, duplicated handlers, and brittle code.
Performance considerations include how quickly the server can resolve a path to a handler. A flat array of exact-match routes is faster than a nested regex chain. For high-traffic APIs, prefer static lookup tables or trie-based routers over dynamic regex evaluation for every request.
Security is also tied to routing: routes define the surface area of your API. Unused routes should not exist; route-level middleware applies authentication only where needed; and parameterized routes must sanitize inputs to prevent injection attacks.
There are three fundamental route types:
- Static — exact match:
GET /about,GET /contact - Parameterized — variable part of path:
GET /users/:id,GET /posts/:postId/comments/:commentId - Wildcard — catch-all:
GET /files/*,GET /static/*pathname
Static routes are the easiest to cache and the fastest to resolve. Parameterized routes require parsing the path segments. Wildcards are useful for file serving or legacy URL compatibility but make authentication and rate-limiting harder because the path is unpredictable.
2. Deep Dive & Implementation
Static and Parameterized Routes
Extract path segments by splitting the pathname:
const url = new URL(request.url);
const parts = url.pathname.split("/");
// /users/42 -> ["", "users", "42"]
const userId = parts[2];
For parameterized routes, validate the extracted parameter before using it. An id extracted from the path should be checked against the expected type (integer, UUID, slug).
Method Routing
HTTP methods map to CRUD operations:
| Method | Purpose | Idempotent |
|---|---|---|
| GET | Read | Yes |
| POST | Create | No |
| PUT | Replace | Yes |
| PATCH | Partial update | No |
| DELETE | Remove | Yes |
Idempotent means calling it multiple times has the same effect as calling once. PUT and DELETE are idempotent; POST is not (each call creates another resource). This matters for retry safety on unreliable networks.
In Bun, combine path and method checks:
Bun.serve({
port: 3000,
fetch(request) {
const url = new URL(request.url);
const path = url.pathname;
if (path === "/users" && request.method === "GET") {
return Response.json({ users: [] });
}
if (path === "/users" && request.method === "POST") {
return Response.json({ created: true }, { status: 201 });
}
if (path.startsWith("/users/") && request.method === "GET") {
const id = path.split("/")[2];
return Response.json({ user: { id } });
}
return new Response("Not Found", { status: 404 });
}
});
Always check the method explicitly. A GET handler that writes to a database violates HTTP semantics and can cause unexpected behavior with caching proxies and browsers.
Query Strings
Query parameters come after ? in the URL:
GET /search?q=fogserv&limit=10&page=2
Parse them with URLSearchParams:
const url = new URL(request.url);
const q = url.searchParams.get("q");
const limit = parseInt(url.searchParams.get("limit") ?? "10", 10);
const page = parseInt(url.searchParams.get("page") ?? "1", 10);
Query strings are for optional filtering, sorting, and pagination — not for resource identification. The resource identity should always come from the path (/users/42), not the query (?id=42). This separation makes URLs predictable and cache-friendly.
Always validate query parameters. Unvalidated limit values can cause denial of service if a user requests limit=100000. Cap pagination limits and sanitize inputs before passing them to databases.
Route Groups and Prefixes
As APIs grow, you need groups: a prefix applied to multiple routes, shared middleware, or versioned paths. Common patterns include version prefixes (/v1/, /v2/) and feature groups (/admin/, /public/).
In raw Bun.serve(), implement groups by checking the prefix before specific routes:
Bun.serve({
port: 3000,
async fetch(request) {
const url = new URL(request.url);
const path = url.pathname;
// Admin group — requires auth check
if (path.startsWith("/admin/")) {
return handleAdmin(request, url);
}
// Public group — CORS enabled
if (path.startsWith("/public/")) {
return handlePublic(request, url);
}
return new Response("Not Found", { status: 404 });
}
});
Using a router library, this becomes a first-class feature. Route groups reduce duplication: apply CORS to /public/ but require auth for /admin/. Without groups, every route repeats the same checks.
Guided Checkpoint
Test the router with a variety of requests:
# Start: bun run server.ts
# Static route — GET returns 200
curl -s -w "\n%{http_code}" http://localhost:3000/users
# Wrong method — GET on POST-only route returns 404 (no handler)
curl -s -w "\n%{http_code}" -X DELETE http://localhost:3000/users
# Parameter extraction — :id parsed from path
curl -s http://localhost:3000/users/99 | jq .
# Nested parameterized route
curl -s http://localhost:3000/posts/5/comments/12 | jq .
# Query string — filtering via URLSearchParams
curl -s "http://localhost:3000/search?q=bun&limit=5" | jq .
# Pagination — offset and limit
curl -s "http://localhost:3000/users?page=2&limit=10" | jq .
# Wildcard — catch-all serves static content
curl -s http://localhost:3000/static/js/app.js | head -c 100
# Unmatched path — returns 404
curl -s -w "\n%{http_code}" http://localhost:3000/nonexistent/path
# Versioned group
curl -s http://localhost:3000/v1/status | jq .
The router should return the correct status code and response body for each case. Unmatched paths must return 404, not 200.
3. Anti-Patterns & Common Pitfalls
Overusing wildcards. Wildcards (/files/*) make URL matching unpredictable and break caching because proxies cannot know what resources will be served. Prefer parameterized routes (/files/:path) for variable segments.
Ignoring HTTP methods. A path like /users should behave differently under GET vs POST. Always branch on request.method. A GET handler that writes to a database violates HTTP semantics and will cause issues with caching proxies and browser preloading.
Query parameters for resource identity. Do not put resource IDs in query strings when a path parameter works. Cache and bookmark behavior differ: GET /users/42 is cacheable and bookmarkable; GET /users?id=42 is not.
No 404 handling. Every router needs a fallback. Return 404 for unmatched paths, not 200 with an empty body. Clients relying on status codes will treat an empty 200 as a successful response.
Regex without bounds. Complex regex routes are slow and hard to maintain. A simple pathname.startsWith("/users/") check is faster and more readable than /^\/users\/[a-z0-9-]+$/i.
Unvalidated path parameters. Extracting :id from /users/abc and passing it to a SQL query without validation causes injection vulnerabilities. Always validate extracted parameters against the expected format (integer, UUID, etc.) before use.
Not returning 405 Method Not Allowed for supported paths with wrong methods. If /users supports GET and POST but not DELETE, returning 404 for DELETE is misleading. The path exists — the method does not. Return 405 with an Allow header listing supported methods.
4. Independent Challenge
Design a versioned API with nested resources that handles the following routes:
GET /v1/posts → list posts (paginated: ?page, ?limit)
POST /v1/posts → create a post
GET /v1/posts/:postId → read a single post
DELETE /v1/posts/:postId → delete a post
GET /v1/posts/:postId/comments → list comments on a post
POST /v1/posts/:postId/comments → add a comment
GET /v1/users/:userId → read a user profile
Implement the router in Bun without a router library. Return realistic JSON responses (use an in-memory array as a fake database). Handle query string pagination for the list endpoints. Return 404 for unmatched paths and 405 for unsupported methods on matched paths. Include an X-Api-Version: v1 response header on all routes.
5. Consolidation & Key Invariants
- Return
404for unmatched paths; never return200with an empty body - Return
405 Method Not Allowedwith anAllowheader for matched paths with unsupported methods - Check
request.methodexplicitly for every route — never ignore the HTTP verb - Resource identity belongs in the path, not the query string:
/users/42, not?id=42 - Validate all extracted path parameters (id, slug, UUID) before using them in queries or logic
- Cap query string pagination values: a client requesting
limit=1000000can exhaust server resources - Prefer simple string operations (
===,startsWith,split) over regex for common routing - Wildcard routes (
/*) should be a last resort; parameterized routes are more predictable and cacheable
6. Next Steps
- Read
kb/backend/middleware-patterns.mdfor auth, logging, and composable middleware. - Read
kb/backend/bun-http.mdfor streaming, WebSockets, and response patterns. - Read
kb/backend/rest-api-design.mdfor resource modeling and idempotency conventions. - Design a versioned API structure (
/v1/users,/v1/posts) and implement route groups with prefix-based middleware.
Change Log
- 2026-08-29: Initial scaffold
- 2026-08-29: Hydrated to production depth — expanded all sections, added code examples, Common Pitfalls, Next Steps, and Change Log sections
- 2026-08-29: Migrated to 6-section canonical template (2026-08-29)