Build a REST API in Node.js with no framework (tested)
Learn what Express does for you by building a small CRUD API with Node's built-in modules. The code was run and tested before publishing.

Frameworks like Express hide what an HTTP server really does. Building one small API with only Node's built-in modules shows you the parts: routing, reading a request body, validation, status codes and tests. After this, a framework will make more sense because you know what it replaces. The code and outputs below were run with Node.js 22 before publishing. Node 18 or newer should work.
What we are building
A notes API that keeps data in memory:
| Method and path | What it does | Success status |
|---|---|---|
GET /notes |
list all notes | 200 |
POST /notes |
create a note | 201 |
GET /notes/:id |
read one note | 200 |
PUT /notes/:id |
replace title and body | 200 |
DELETE /notes/:id |
delete a note | 204 |
The code
Save as server.mjs. No npm install is needed:
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
const notes = new Map();
function send(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(body === undefined ? "" : JSON.stringify(body));
}
async function readJson(req) {
let raw = "";
for await (const chunk of req) {
raw += chunk;
if (raw.length > 100_000) throw Object.assign(new Error("Body too large"), { status: 413 });
}
try {
return JSON.parse(raw || "{}");
} catch {
throw Object.assign(new Error("Invalid JSON"), { status: 400 });
}
}
function validate(data) {
if (typeof data.title !== "string" || !data.title.trim()) return "title is required";
if (data.title.length > 100) return "title must be 100 characters or fewer";
if (data.body !== undefined && typeof data.body !== "string") return "body must be a string";
return null;
}
export const server = createServer(async (req, res) => {
try {
const { pathname } = new URL(req.url, "http://localhost");
const parts = pathname.split("/").filter(Boolean);
if (parts[0] !== "notes" || parts.length > 2) return send(res, 404, { error: "Not found" });
const id = parts[1];
if (!id && req.method === "GET") return send(res, 200, [...notes.values()]);
if (!id && req.method === "POST") {
const data = await readJson(req);
const problem = validate(data);
if (problem) return send(res, 400, { error: problem });
const note = { id: randomUUID(), title: data.title.trim(), body: data.body ?? "", createdAt: new Date().toISOString() };
notes.set(note.id, note);
return send(res, 201, note);
}
if (id) {
const note = notes.get(id);
if (!note) return send(res, 404, { error: "Note not found" });
if (req.method === "GET") return send(res, 200, note);
if (req.method === "PUT") {
const data = await readJson(req);
const problem = validate(data);
if (problem) return send(res, 400, { error: problem });
Object.assign(note, { title: data.title.trim(), body: data.body ?? "" });
return send(res, 200, note);
}
if (req.method === "DELETE") {
notes.delete(id);
return send(res, 204);
}
}
send(res, 405, { error: "Method not allowed" });
} catch (err) {
send(res, err.status ?? 500, { error: err.status ? err.message : "Server error" });
}
});
if (process.argv[1] === new URL(import.meta.url).pathname) {
const port = process.env.PORT ?? 3000;
server.listen(port, () => console.log(`Notes API on http://localhost:${port}`));
}
Run it and try it
$ PORT=3917 node server.mjs
Notes API on http://localhost:3917
$ curl -i -X POST localhost:3917/notes -H 'Content-Type: application/json' \
-d '{"title":"Buy milk","body":"2 litres"}'
HTTP/1.1 201 Created
{"id":"7221c578-...","title":"Buy milk","body":"2 litres","createdAt":"2026-10-27T06:45:58.682Z"}
$ curl -s -X POST localhost:3917/notes -d 'not json'
{"error":"Invalid JSON"} # status 400
$ curl -s -X POST localhost:3917/notes -H 'Content-Type: application/json' -d '{"body":"x"}'
{"error":"title is required"} # status 400
$ curl -s localhost:3917/notes/unknown-id
{"error":"Note not found"} # status 404
The id and time will differ on your machine.
How it works
Routing. We split the path into parts. /notes has one part, /notes/abc has two. Anything else gets 404. A framework does this with a router; here it is a few lines.
Reading the body. A request body arrives in chunks. for await (const chunk of req) collects them. We stop at 100,000 characters so one client cannot make the server hold unlimited data in memory, and we return 413 in that case.
Validation. Never trust input. validate checks that the title is a non-empty string of at most 100 characters and that the body, if present, is a string. Bad input returns 400 with a message the client can show.
Status codes. 201 Created for a new note, 204 No Content for a delete with nothing to return, 400 for bad input, 404 for a missing note, 405 for a method that is not allowed, 413 for a body that is too large.
Error handling. One try/catch wraps the handler. Errors we raise on purpose carry a status. Anything unexpected becomes a plain 500 with a generic message, so internal details do not leak to the client.
IDs. crypto.randomUUID() makes hard-to-guess ids, so a visitor cannot simply count up from 1 to read every note.
The process.argv[1] check. The server starts only when you run the file directly. When a test imports it, nothing listens on a fixed port.
Test it with node:test
Node has a built-in test runner and a built-in fetch. Save as server.test.mjs:
import test from "node:test";
import assert from "node:assert/strict";
import { server } from "./server.mjs";
let base;
test.before(async () => {
await new Promise((resolve) => server.listen(0, resolve));
base = `http://localhost:${server.address().port}`;
});
test.after(() => server.close());
const json = (method, path, body) =>
fetch(base + path, { method, headers: { "Content-Type": "application/json" }, body: body && JSON.stringify(body) });
test("create, read, update and delete a note", async () => {
const created = await json("POST", "/notes", { title: "Learn HTTP", body: "status codes" });
assert.equal(created.status, 201);
const note = await created.json();
assert.equal((await json("GET", `/notes/${note.id}`)).status, 200);
const updated = await json("PUT", `/notes/${note.id}`, { title: "Learn HTTP well" });
assert.equal((await updated.json()).title, "Learn HTTP well");
assert.equal((await json("DELETE", `/notes/${note.id}`)).status, 204);
assert.equal((await json("GET", `/notes/${note.id}`)).status, 404);
});
test("rejects a note without a title", async () => {
const res = await json("POST", "/notes", { body: "no title" });
assert.equal(res.status, 400);
assert.deepEqual(await res.json(), { error: "title is required" });
});
Run:
$ node --test
✔ create, read, update and delete a note
✔ rejects a note without a title
# tests 2
# pass 2
# fail 0
Listening on port 0 asks the operating system for any free port, so tests never clash with another program.
Known limits
- Data is lost on restart because it lives in a
Map. Next step: store notes in SQLite or Postgres. - No authentication. Anyone who can reach the server can read and delete notes. Do not expose it to the internet as it is.
- No rate limiting and no CORS headers. A browser app on another origin would be blocked until you add CORS.
- Simple routing. For many routes, a framework or a small router library is easier to maintain.
- No
PATCHfor partial updates;PUTreplaces the title and body.
Challenges
- Add
GET /notes?search=milkto filter by title. - Save notes to a JSON file so they survive restarts.
- Add a
PATCHroute that updates only the fields sent. - Return
415when theContent-Typeis not JSON. - Rewrite the same API with Express and compare how much code the framework saves.
Takeaway: an HTTP API is routes, parsed input, validation, correct status codes and tests. Once you have built it by hand, frameworks become tools you understand rather than magic.
Build-along projects series, Part 1 (small tested projects): 2 of 5. Previous: Build a Python expense tracker CLI in 60 lines (with tests).


