Build a notes REST API with plain Node.js: CRUD, validation, status codes and CORS
Write a small JSON API for notes using only Node.js built-ins: create, read, update and delete, input validation, correct HTTP status codes, curl tests, automated tests and CORS for a browser page.
Frameworks such as Express hide a lot of what an HTTP API actually does. In this tutorial you build a notes API with nothing but Node.js built-in modules, so every status code, header and error message is something you wrote and understand. The result is small enough to read in one sitting but handles the cases real APIs must: bad JSON, missing fields, unknown IDs, wrong methods, oversized bodies and browser CORS rules.
It is for developers who know JavaScript basics (functions, objects, async/await) and have used an API before. The whole tutorial was run end to end with Node.js on macOS: every curl output below is real, and the browser part was checked in Chrome.
What you will build
- An HTTP server on
http://127.0.0.1:3000with these routes:GET /notes,POST /notes,GET /notes/:id,PATCH /notes/:idandDELETE /notes/:id. - Notes stored in a
notes.jsonfile, written safely. - JSON error responses with the right status codes: 400, 404, 405, 413, 415, 422 and 500.
- CORS headers that let one specific browser origin call the API, including preflight requests.
- Automated tests with the built-in
node:testrunner, and a tiny HTML client.
Before you start
- Node.js 20 or newer (the built-in test runner is stable from Node 20, and
fetchis built in). Check withnode --version. - curl, which ships with macOS, Windows 10 and later, and most Linux distributions.
- Python 3 is used once to serve a static HTML page; any static file server works.
- Time: about 60-90 minutes.
- Knowledge: HTTP basics (methods, headers, status codes), JSON, and JavaScript promises. Knowing what CORS is helps for the last part.
Step 1: Create the project
Why: "type": "module" lets you use import syntax in .js files. There are no dependencies to install.
mkdir notes-api
cd notes-api
mkdir test client
Create package.json:
{
"name": "notes-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js",
"test": "node --test"
}
}
Step 2: Store notes in a JSON file
Why: a JSON file is enough for learning and keeps the focus on HTTP. The store reads the whole list and writes the whole list. Writing goes to a temporary file that is then renamed over the real one, so a crash never leaves a half-written file.
Create store.js:
import { readFile, writeFile, rename } from 'node:fs/promises';
export function createStore(file) {
async function readAll() {
try {
return JSON.parse(await readFile(file, 'utf8'));
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
}
async function writeAll(notes) {
const tmp = `${file}.tmp`;
await writeFile(tmp, JSON.stringify(notes, null, 2) + '\n');
await rename(tmp, file);
}
return { readAll, writeAll };
}
A missing file (ENOENT) simply means “no notes yet”. Any other error, such as a corrupted file, is re-thrown and will turn into a 500 response. This design reads and writes everything on each request and has no locking, so it is fine for one user and a few hundred notes, not for production traffic.
Step 3: Plan the routes and status codes
Why: deciding the contract first keeps the code honest. Every response body is JSON, and every error has the shape {"error": "message"}, with an optional details object.
200 OK: a successful read or update; the body is the note or the list.201 Created: a note was created; the body is the new note and theLocationheader is its URL.204 No Content: a successful delete or a CORS preflight; no body.400 Bad Request: the body is not valid JSON or not a JSON object.404 Not Found: unknown path or unknown note ID.405 Method Not Allowed: for examplePUT /notes; theAllowheader lists what is allowed.413: the body is larger than the limit (10,000 bytes here).415 Unsupported Media Type: the request did not sayContent-Type: application/json.422 Unprocessable Content: valid JSON, but the data breaks the rules (for example an empty title). Some APIs use 400 for this too; choose one and be consistent.500 Internal Server Error: a bug or a broken data file. The details go to the server log, not to the client.
Step 4: Write the request handler
Why: keeping the handler in app.js, separate from the code that opens a port, lets the tests start the same app on a random port.
Create app.js:
import { randomUUID } from 'node:crypto';
const MAX_BODY_BYTES = 10_000;
const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? 'http://localhost:5500').split(',');
class HttpError extends Error {
constructor(status, message, headers = {}) {
super(message);
this.status = status;
this.headers = headers;
}
}
function send(res, status, data, headers = {}) {
const body = data === undefined ? '' : JSON.stringify(data);
res.writeHead(status, {
...(body && { 'Content-Type': 'application/json; charset=utf-8' }),
...headers,
});
res.end(body);
}
function applyCors(req, res) {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
}
async function readJson(req) {
const type = req.headers['content-type'] ?? '';
if (!type.startsWith('application/json')) {
throw new HttpError(415, 'Content-Type must be application/json');
}
let size = 0;
const chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw new HttpError(413, `Body larger than ${MAX_BODY_BYTES} bytes`);
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw new HttpError(400, 'Body is not valid JSON');
}
}
function validate(input, { partial = false } = {}) {
const errors = {};
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
throw new HttpError(400, 'Body must be a JSON object');
}
if (!partial || 'title' in input) {
if (typeof input.title !== 'string' || input.title.trim() === '') errors.title = 'required, non-empty string';
else if (input.title.length > 100) errors.title = 'at most 100 characters';
}
if ('body' in input && typeof input.body !== 'string') errors.body = 'must be a string';
if (Object.keys(errors).length) {
const err = new HttpError(422, 'Validation failed');
err.details = errors;
throw err;
}
const clean = {};
if ('title' in input) clean.title = input.title.trim();
if ('body' in input) clean.body = input.body;
return clean;
}
export function createApp(store) {
async function route(req, res) {
const url = new URL(req.url, 'http://localhost');
const parts = url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'notes' || parts.length > 2) throw new HttpError(404, 'Not found');
const id = parts[1];
const allowed = id ? 'GET, PATCH, DELETE, OPTIONS' : 'GET, POST, OPTIONS';
if (req.method === 'OPTIONS') {
return send(res, 204, undefined, {
Allow: allowed,
'Access-Control-Allow-Methods': allowed,
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '600',
});
}
const notes = await store.readAll();
if (!id && req.method === 'GET') return send(res, 200, notes);
if (!id && req.method === 'POST') {
const data = validate(await readJson(req));
const now = new Date().toISOString();
const note = { id: randomUUID(), title: data.title, body: data.body ?? '', createdAt: now, updatedAt: now };
notes.push(note);
await store.writeAll(notes);
return send(res, 201, note, { Location: `/notes/${note.id}` });
}
if (id) {
const index = notes.findIndex((n) => n.id === id);
if (['GET', 'PATCH', 'DELETE'].includes(req.method) && index === -1) {
throw new HttpError(404, `No note with id ${id}`);
}
if (req.method === 'GET') return send(res, 200, notes[index]);
if (req.method === 'PATCH') {
const data = validate(await readJson(req), { partial: true });
notes[index] = { ...notes[index], ...data, updatedAt: new Date().toISOString() };
await store.writeAll(notes);
return send(res, 200, notes[index]);
}
if (req.method === 'DELETE') {
notes.splice(index, 1);
await store.writeAll(notes);
return send(res, 204);
}
}
throw new HttpError(405, `Method ${req.method} not allowed`, { Allow: allowed });
}
return async function handler(req, res) {
applyCors(req, res);
try {
await route(req, res);
} catch (err) {
if (err instanceof HttpError) {
return send(res, err.status, { error: err.message, ...(err.details && { details: err.details }) }, err.headers);
}
console.error(err);
send(res, 500, { error: 'Internal server error' });
}
};
}
Key ideas:
HttpErrorcarries a status code. Route code justthrows it, and onecatchat the bottom turns it into a JSON response. Anything else is an unexpected bug and becomes a 500.readJsonchecks theContent-Typefirst (415), counts bytes as the body streams in and stops at the limit (413), then parses JSON (400).validatereturns a clean copy that contains only known fields, so a client cannot inject extra properties such asidorcreatedAt. ForPATCH, fields are optional but still checked when present.crypto.randomUUID()creates IDs that are hard to guess and never collide in practice.- The
Allowheader on 405 responses is required by the HTTP specification and tells clients what they can do instead.
Step 5: Start the server
Create server.js. Binding to 127.0.0.1 keeps the API reachable only from your own computer while you develop.
import { createServer } from 'node:http';
import { createApp } from './app.js';
import { createStore } from './store.js';
const port = Number(process.env.PORT ?? 3000);
const store = createStore(process.env.NOTES_FILE ?? 'notes.json');
createServer(createApp(store)).listen(port, '127.0.0.1', () => {
console.log(`Notes API listening on http://127.0.0.1:${port}`);
});
npm start
> [email protected] start
> node server.js
Notes API listening on http://127.0.0.1:3000
Leave it running and open a second terminal for the next steps. Stop it later with Ctrl+C.
Step 6: Exercise every route with curl
Why: curl shows you the raw status line and headers, which is exactly what you want to check. -i includes response headers; -w ' %{http_code}\n' prints the status code after the body.
curl -s http://127.0.0.1:3000/notes
curl -i -s -X POST http://127.0.0.1:3000/notes \
-H 'Content-Type: application/json' \
-d '{"title":"Buy milk","body":"2 liters"}'
[]
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /notes/82d375cf-fcd2-463e-8a8c-6197f43a994e
...
{"id":"82d375cf-fcd2-463e-8a8c-6197f43a994e","title":"Buy milk","body":"2 liters","createdAt":"2026-10-15T07:51:05.072Z","updatedAt":"2026-10-15T07:51:05.072Z"}
Copy the ID from your own output into a shell variable, then read, update and delete the note:
ID=82d375cf-fcd2-463e-8a8c-6197f43a994e
curl -s http://127.0.0.1:3000/notes/$ID
curl -s -X PATCH http://127.0.0.1:3000/notes/$ID \
-H 'Content-Type: application/json' -d '{"body":"3 liters"}'
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE http://127.0.0.1:3000/notes/$ID
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE http://127.0.0.1:3000/notes/$ID
The PATCH response shows "body":"3 liters" and a newer updatedAt. The first DELETE prints 204; the second prints 404 because the note is gone.
Now the error cases. Each line was run against the server above:
B=http://127.0.0.1:3000
curl -s -w ' %{http_code}\n' -X POST $B/notes -H 'Content-Type: application/json' -d '{"title":""}'
curl -s -w ' %{http_code}\n' -X POST $B/notes -H 'Content-Type: application/json' -d '{"title":'
curl -s -w ' %{http_code}\n' -X POST $B/notes -d 'title=x'
curl -s -w ' %{http_code}\n' $B/notes/nope
curl -s -D - -o /dev/null -X PUT $B/notes
curl -s -w ' %{http_code}\n' $B/users
{"error":"Validation failed","details":{"title":"required, non-empty string"}} 422
{"error":"Body is not valid JSON"} 400
{"error":"Content-Type must be application/json"} 415
{"error":"No note with id nope"} 404
HTTP/1.1 405 Method Not Allowed
Allow: GET, POST, OPTIONS
...
{"error":"Not found"} 404
The third command gets 415 because curl -d sends application/x-www-form-urlencoded unless you set the header yourself, a common mistake in real clients too.
Step 7: Add automated tests
Why: curl checks are great while building, but tests repeat them in a second. The test starts the app on port 0 (the operating system picks a free port) with a data file in a temporary folder, and uses the built-in fetch.
Create test/api.test.js:
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createApp } from '../app.js';
import { createStore } from '../store.js';
let server;
let base;
before(async () => {
const dir = await mkdtemp(join(tmpdir(), 'notes-'));
server = createServer(createApp(createStore(join(dir, 'notes.json'))));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => server.close());
const json = (method, body) => ({
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
test('create, read, update and delete a note', async () => {
let res = await fetch(`${base}/notes`, json('POST', { title: 'Test', body: 'hello' }));
assert.equal(res.status, 201);
const note = await res.json();
assert.equal(res.headers.get('location'), `/notes/${note.id}`);
res = await fetch(`${base}/notes/${note.id}`, json('PATCH', { title: 'Renamed' }));
assert.equal((await res.json()).title, 'Renamed');
res = await fetch(`${base}/notes/${note.id}`, { method: 'DELETE' });
assert.equal(res.status, 204);
res = await fetch(`${base}/notes/${note.id}`);
assert.equal(res.status, 404);
});
test('rejects a note without a title', async () => {
const res = await fetch(`${base}/notes`, json('POST', { body: 'no title' }));
assert.equal(res.status, 422);
assert.deepEqual((await res.json()).details, { title: 'required, non-empty string' });
});
test('answers a CORS preflight for the allowed origin', async () => {
const res = await fetch(`${base}/notes`, {
method: 'OPTIONS',
headers: { Origin: 'http://localhost:5500', 'Access-Control-Request-Method': 'POST' },
});
assert.equal(res.status, 204);
assert.equal(res.headers.get('access-control-allow-origin'), 'http://localhost:5500');
});
npm test
✔ create, read, update and delete a note
✔ rejects a note without a title
✔ answers a CORS preflight for the allowed origin
ℹ tests 3
ℹ pass 3
ℹ fail 0
With no file arguments, node --test finds files matching patterns such as **/*.test.js and anything under a test folder.
Step 8: Call the API from a browser page with CORS
Why: browsers enforce the same-origin policy. A page served from http://localhost:5500 is a different origin from an API on http://127.0.0.1:3000 (scheme, host and port must all match), so the browser only lets the page read responses if the API says so with CORS headers. Because the client sends Content-Type: application/json, the browser first sends a preflight OPTIONS request to ask for permission.
The handler already does the work: applyCors echoes the Origin back in Access-Control-Allow-Origin only when it is in the allow list (from the CORS_ORIGINS environment variable, default http://localhost:5500), adds Vary: Origin so caches do not mix up responses for different origins, and the OPTIONS branch answers preflights with the allowed methods and headers. Check the preflight with curl:
curl -s -i -X OPTIONS http://127.0.0.1:3000/notes \
-H 'Origin: http://localhost:5500' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5500
Vary: Origin
Allow: GET, POST, OPTIONS
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 600
Create client/index.html, a minimal page that lists notes and adds one:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Notes client</title>
</head>
<body>
<h1>Notes</h1>
<form id="new-note">
<label for="title">Title</label>
<input id="title" name="title" required>
<button>Add note</button>
</form>
<p id="status" role="status"></p>
<ul id="notes"></ul>
<script>
const API = 'http://127.0.0.1:3000/notes';
const list = document.querySelector('#notes');
const status = document.querySelector('#status');
async function load() {
const notes = await (await fetch(API)).json();
list.replaceChildren(...notes.map((n) => {
const li = document.createElement('li');
li.textContent = n.title;
return li;
}));
}
document.querySelector('#new-note').addEventListener('submit', async (event) => {
event.preventDefault();
const title = event.target.title.value;
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
const data = await res.json();
status.textContent = res.ok ? `Saved "${data.title}"` : `Error ${res.status}: ${data.error}`;
if (res.ok) {
event.target.reset();
load();
}
});
load().catch((err) => { status.textContent = `Could not reach the API: ${err.message}`; });
</script>
</body>
</html>
Serve it on port 5500 in another terminal and open http://localhost:5500 in a browser:
cd client
python3 -m http.server 5500
Add a note through the form; it appears in the list and the status line says Saved "...". Now open http://127.0.0.1:5500 instead. That is a different origin, not in the allow list, so the page shows Could not reach the API: Failed to fetch and Chrome’s console shows:
Access to fetch at 'http://127.0.0.1:3000/notes' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
That is CORS working as designed. To allow another origin, start the server with CORS_ORIGINS=http://localhost:5500,http://127.0.0.1:5500 npm start. Avoid Access-Control-Allow-Origin: * for anything that uses cookies or other credentials.
Check that it works
- Stop all servers, delete
notes.jsonif it exists, and runnpm test: three tests pass. - Run
npm start, thencurl -s http://127.0.0.1:3000/notes: prints[]. - Create a note with the POST command from step 6:
201with aLocationheader, andcat notes.jsonshows it. - Open
http://localhost:5500(static server running) and add a note: it appears in the page and incurl -s http://127.0.0.1:3000/notes.
If something goes wrong
Error: listen EADDRINUSE: address already in use 127.0.0.1:3000. Another process holds the port, often an earlier copy of this server. Stop it, or start on another port withPORT=3001 npm startand update the client’sAPIconstant.SyntaxError: Cannot use import statement outside a module.package.jsonis missing"type": "module", or you are running the file from another folder.- Every POST returns 415. The client is not sending
Content-Type: application/json. Add the header (curl:-H 'Content-Type: application/json'). - The browser shows a CORS error but curl works. curl does not enforce CORS; browsers do. Compare the page’s exact origin (scheme, host and port) with
CORS_ORIGINS;localhostand127.0.0.1are different origins. - A 500 response with
Internal server error. Read the server terminal: the real error is logged there. A hand-edited, invalidnotes.jsonis the usual cause.
Clean up
Stop both servers with Ctrl+C in their terminals. The only data is notes.json in the project folder. Warning: the next command deletes the project including your notes; check the path with pwd first.
cd ..
rm -r notes-api
Next steps
- Replace the JSON file with SQLite so concurrent requests cannot overwrite each other.
- Add pagination to
GET /noteswith?limit=and?offset=query parameters, validated like the body. - Add authentication with an API key or bearer token, returning 401 when it is missing and 403 when it is not allowed.
- Rebuild the same contract with Express or Fastify and keep the tests unchanged as a safety net.
- Describe the API in an OpenAPI document so clients can be generated.
Official documentation
See also
- 0019 fetch with timeout, retries and proper error handling A reusable fetchJson helper for Node 20+ and browsers that adds a timeout, retries with backoff for temporary failures, and clear errors… Intermediate · How-to · Any system Script Async code · Making requests 15 Oct 2026
- 0021 Read and write files in Node.js with fs/promises A complete Node script that walks a folder, reads text files, and writes a JSON report safely, showing the everyday fs/promises calls… Beginner · How-to · Any system Script Node.js 15 Oct 2026
- 0022 Build a small Node.js CLI with util.parseArgs A complete, dependency-free command-line tool in Node: options and flags with parseArgs, input from files or a pipe, help text, and meaningful… Intermediate · How-to · Any system Script Node.js 15 Oct 2026
- 0027 Fix: SyntaxError: Cannot use import statement outside a module The file uses ES module import syntax, but Node or the browser is loading it as a classic script or CommonJS. Tell… Intermediate · Fix · Any system Article Common errors · Node.js 15 Oct 2026
- 0081 How an HTTP request works: methods, headers, and body An HTTP request is a short text message a client sends to a server: a method and path, some headers, and sometimes… Beginner · Concept · Any system Article Making requests 15 Oct 2026
- 0082 HTTP status codes: the common ones and what to do A quick reference to the HTTP status codes you will meet most often, what each one means, and the first thing to… Beginner · Reference · Any system Article References · Status codes 15 Oct 2026
Repos in the wild
- Requests: HTTP for humans psf/requests Requests is the classic Python library for calling web APIs and downloading pages, with a simple API for GET and POST requests, headers, JSON and sessions. GitHub
- Node.js nodejs/node The source of the Node.js runtime: a JavaScript engine (V8) plus APIs for files, networking, processes and more, used to run JavaScript outside the browser. GitHub
- Axios axios/axios A promise-based HTTP client for browsers and Node.js with interceptors, automatic JSON handling, request cancellation and a consistent API in both environments. GitHub
- curl and libcurl (curl/curl) curl/curl The source of curl, the command-line tool for transferring data with URLs, and libcurl, the C library behind it that many other programs use for HTTP. GitHub
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- commitpedia-admin +23,109 chars