| name | vanilla-ts-http-route-handler |
| description | How to correctly add route handlers in a vanilla TypeScript project that uses http.createServer with a manual switch/case router — without introducing Express Router or Next.js patterns. |
Adding Route Handlers to a Vanilla TypeScript HTTP Server
Use this skill whenever you need to add a new route to a TypeScript project
that uses Node's built-in http module and a switch/case pathname router
without Express or Next.js.
Step 0 — Confirm the routing style
Before writing any code, inspect server/index.ts (or whichever file starts
the HTTP server). Look for:
http.createServer((req, res) => {
const { pathname } = new URL(req.url!, `http://${req.headers.host}`);
switch (pathname) {
case '/api/foo': ...
}
});
If you see this pattern, follow the steps below.
Do NOT use express.Router, next/server, or any framework-specific
handler signature — even if other files in server/routes/ happen to
use those patterns.
Step 1 — Create the route file
Create server/routes/<feature>.ts.
Use only the native Node types: http.IncomingMessage and
http.ServerResponse.
import http from 'http';
export async function handleHealth(
req: http.IncomingMessage,
res: http.ServerResponse
): Promise<void> {
const body = await readBody(req);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
}
Rules for the route file:
- Export the handler as a named async function (
handle<Feature>).
- Accept exactly
(req: http.IncomingMessage, res: http.ServerResponse).
- Return
Promise<void>.
- Do not call
next() or return a Response object.
- Handle errors internally and write an appropriate HTTP status + JSON body.
Step 2 — Import the handler in server/index.ts
Add a named import at the top of the file alongside any existing imports:
import { handleHealth } from './routes/health';
Step 3 — Add a case entry to the switch block
Locate the existing switch (pathname) block and add a new case:
switch (pathname) {
case '/api/health':
await handleHealth(req, res);
break;
default:
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
}
- Match the exact pathname string used by the client.
- Always
break (or return) after calling the handler.
- Keep the
default case last.
Step 4 — Optional: shared body-reading helper
If multiple routes need to parse a JSON request body, add a small utility
rather than duplicating the logic:
import http from 'http';
export function readBody(req: http.IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk) => (data += chunk));
req.on('end', () => {
try {
resolve(data ? JSON.parse(data) : {});
} catch {
reject(new Error('Invalid JSON'));
}
});
req.on('error', reject);
});
}
Import and use it inside any route handler that needs it.
Checklist
Anti-patterns to avoid
| ❌ Wrong | ✅ Correct |
|---|
import { Router } from 'express' | import http from 'http' |
export default function handler(req: NextApiRequest, ...) | export async function handleFoo(req: http.IncomingMessage, ...) |
router.get('/foo', ...) | case '/api/foo': await handleFoo(req, res); break; |
| Returning a value from the handler | Calling res.end(...) and returning void |