Your first Node.js server: static files and a JSON API with no framework
What Node.js is, how to run a script, a working HTTP server that serves files safely and answers a JSON endpoint, where frameworks like Express fit in, and how to keep it running.
Node.js runs JavaScript outside the browser, which makes it the natural first server language for someone who already writes front-end code. This tutorial builds a small server with only the modules that come with Node: it serves the files in a folder and answers a JSON request. Doing it once without a framework shows what Express and its relatives actually do for you, and gives you a server you fully understand. It was tested on Node.js 22 LTS; anything from 18 onwards works.
Install and run
Install the LTS version from nodejs.org (or with a version manager such as nvm or fnm if you will need several versions). Check it:
$ node --version
v22.11.0
$ echo 'console.log(1 + 1)' > hello.js
$ node hello.js
2Node runs a file top to bottom and exits when there is nothing left to do. A server keeps it alive because the listening socket is "something left to do". Create a project folder with npm init -y, which writes a package.json; add "type": "module" to it so you can use import instead of require.
The smallest server
// server.js
import { createServer } from 'node:http';
const server = createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Hello from Node\n');
});
server.listen(3000, () => console.log('http://localhost:3000'));Run node server.js and open the address. The callback runs once per request; request carries the method, URL and headers, and you write the status, headers and body to response. end() sends the body and finishes. The node: prefix marks a built-in module, as opposed to one from node_modules.
Routing by hand
A server for a real page needs to do different things for different URLs. Parse the URL once, then branch:
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { join, extname, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const PUBLIC = fileURLToPath(new URL('./public/', import.meta.url));
const TYPES = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
'.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml',
};
const tasks = [{ id: 1, text: 'Write a server', done: false }];
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === '/api/tasks' && req.method === 'GET') {
return sendJSON(res, 200, tasks);
}
if (url.pathname === '/api/tasks' && req.method === 'POST') {
const body = await readJSON(req);
if (!body || typeof body.text !== 'string' || !body.text.trim()) {
return sendJSON(res, 400, { error: 'text is required' });
}
const task = { id: Date.now(), text: body.text.trim(), done: false };
tasks.push(task);
return sendJSON(res, 201, task);
}
return sendFile(res, url.pathname);
});
function sendJSON(res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
async function readJSON(req) {
let text = '';
for await (const chunk of req) {
text += chunk;
if (text.length > 1e6) return null; // refuse bodies over ~1 MB
}
try { return JSON.parse(text); } catch { return null; }
}
async function sendFile(res, pathname) {
// Resolve inside PUBLIC only: "normalize" collapses "../" so a request
// for /../server.js cannot climb out of the folder.
const safe = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '');
let file = join(PUBLIC, safe);
try {
if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
const data = await readFile(file);
res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream' });
res.end(data);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found\n');
}
}
server.listen(3000, () => console.log('http://localhost:3000'));Put an index.html in a public folder next to server.js and start it. / serves the file, /api/tasks returns the array, and a POST adds to it. Try the POST from the browser console:
await (await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'Test' }),
})).json();Three things in the file deserve attention. The request body arrives in chunks, which is why readJSON loops with for await; the size cap stops a client from filling memory. The path check in sendFile is not optional: without it, /../server.js or an encoded equivalent would read your source code, and on a real server, anything else on the disk. And the content type table matters because browsers refuse to run a script served as text/plain and will not apply a stylesheet with the wrong type.
What a framework adds
Everything above is about 60 lines, and a framework such as Express, Fastify or Hono replaces most of it: a router that matches app.get('/api/tasks/:id', handler) with parameters parsed, body parsing with limits, a static file handler with caching headers and the path check done properly, and middleware for logging and errors. Once you know what the framework is doing, use one; you will not write a safer static handler than the maintained one. The same Express route for comparison:
import express from 'express';
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(express.static('public'));
app.get('/api/tasks', (req, res) => res.json(tasks));
app.listen(3000);Keeping it running
- During development,
node --watch server.js(Node 18.11+) restarts on file changes. - Unhandled errors crash the process, which is correct: a process in an unknown state should not keep serving. In production something must restart it: a
systemdunit on Linux, or a process manager such aspm2. - Configuration comes from environment variables, not files you commit:
const PORT = process.env.PORT ?? 3000. - Put a reverse proxy such as Caddy or nginx in front for HTTPS, compression and serving static files; let Node do only the dynamic part.
Pitfalls
- Forgetting to
end(). The browser spins forever. Every branch must send a response. - Blocking the event loop. Node, like the browser, is single-threaded.
readFileSyncin a request handler stalls every other request while the disk is read; use the promise versions. - State in memory. The
tasksarray disappears when the process restarts and is not shared between processes. It is fine for learning; real data goes in a database or at least a file. - Trusting
req.headers.hostand the URL. Both come from the client. Validate anything you use for file paths or redirects. - Port in use.
EADDRINUSEmeans a previous instance is still running; find it withlsof -i :3000(macOS/Linux) and stop it.
Exercise
Add PATCH /api/tasks/:id that toggles done, and DELETE /api/tasks/:id. Parse the id from the path with a regular expression or by splitting on /; return 404 for an unknown id and 400 for a non-numeric one. Then connect the to-do app from the DOM tutorial to it by replacing the localStorage functions with fetch calls.
Summary
node file.jsruns a script; a listening server keeps it alive.createServergives you request and response objects; parse the URL, branch, always respond.- Read bodies in chunks with a size cap; serve files only from inside one folder, with the right content type.
- Frameworks package these patterns; use one once you understand them.