Every web framework (Express, Fastify, Next.js) is built on Node's node:http module. Building a small server with it directly teaches you what the frameworks do for you: matching the method and URL, reading the request body, setting headers and status codes.
Use proper status codes: 200 OK, 201 Created, 400 bad input, 404 not found, 500 unexpected error. Always respond with Content-Type: application/json for JSON APIs.
Read the body by collecting the request stream's chunks, then JSON.parse it inside a try/catch — clients can send anything.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
interface Task {
id: number;
title: string;
done: boolean;
}
const tasks: Task[] = [{ id: 1, title: "Learn TypeScript", done: false }];
let nextId = 2;
function send(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
async function readJson(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
try {
if (req.method === "GET" && url.pathname === "/tasks") {
return send(res, 200, tasks);
}
if (req.method === "POST" && url.pathname === "/tasks") {
const body = await readJson(req);
const title = (body as { title?: unknown }).title;
if (typeof title !== "string" || title.trim() === "") {
return send(res, 400, { error: "title is required" });
}
const task: Task = { id: nextId++, title: title.trim(), done: false };
tasks.push(task);
return send(res, 201, task);
}
send(res, 404, { error: "Not found" });
} catch (err) {
if (err instanceof SyntaxError) return send(res, 400, { error: "Invalid JSON" });
console.error(err);
send(res, 500, { error: "Internal server error" });
}
});
server.listen(3000, () => console.log("Listening on http://localhost:3000"));npx tsx server.ts
curl http://localhost:3000/tasks
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":"Build an API"}'Key points
- A server is a function from request to response — route on method + path.
- Validate every request body; never trust client input.
- Return accurate status codes and log unexpected errors server-side.
Exercise
Add PATCH /tasks/:id to mark a task done and DELETE /tasks/:id to remove it, returning 404 for unknown ids. Then add GET /tasks?done=true filtering using url.searchParams.
Show solution
Try the exercise yourself first — then compare your approach with this one.
Match /tasks/:id with a regular expression to get the id, then look the task up. PATCH marks it done, DELETE removes it, and both return 404 for unknown ids. GET /tasks?done=true filters with url.searchParams.
import { createServer, type ServerResponse } from "node:http";
interface Task {
id: number;
title: string;
done: boolean;
}
let tasks: Task[] = [
{ id: 1, title: "Learn TypeScript", done: true },
{ id: 2, title: "Build an API", done: false },
];
function send(res: ServerResponse, status: number, body?: unknown): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(body === undefined ? "" : JSON.stringify(body));
}
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
if (req.method === "GET" && url.pathname === "/tasks") {
const done = url.searchParams.get("done");
const list = done === null ? tasks : tasks.filter((t) => t.done === (done === "true"));
return send(res, 200, list);
}
const match = url.pathname.match(/^\/tasks\/(\d+)$/);
if (match) {
const id = Number(match[1]);
const task = tasks.find((t) => t.id === id);
if (!task) return send(res, 404, { error: "Task not found" });
if (req.method === "PATCH") {
task.done = true;
return send(res, 200, task);
}
if (req.method === "DELETE") {
tasks = tasks.filter((t) => t.id !== id);
return send(res, 204);
}
}
send(res, 404, { error: "Not found" });
});
server.listen(3000, () => console.log("Listening on http://localhost:3000"));curl "http://localhost:3000/tasks?done=false" # [{"id":2,"title":"Build an API","done":false}]
curl -X PATCH http://localhost:3000/tasks/2 # {"id":2,"title":"Build an API","done":true}
curl -X DELETE http://localhost:3000/tasks/1 # (204 No Content)
curl -X PATCH http://localhost:3000/tasks/99 # {"error":"Task not found"}