Serving static files
Static files are the ones sent to the browser as they are, without processing: HTML pages, styles, scripts, images. Frameworks serve them with one line of configuration, but with node:http the server itself must find the file on disk, read it and set the right Content-Type.
The public folder
Create a public folder in the project root with two files in it:
blog/
├── public/
│ ├── index.html
│ └── style.css
├── src/
│ └── server.js
└── package.json<!-- public/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Notes by Anna</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<h1>Notes by Anna</h1>
</body>
</html>/* public/style.css */
h1 {
color: darkslateblue;
}Type by extension
The browser knows what it received only from the Content-Type header. The mapping from extensions to types is an object:
// src/static.js
export const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
};For unknown extensions the type application/octet-stream — "just bytes" — is used.
The serving function
Add to src/static.js a function that looks for a file in public and sends it:
// src/static.js
import path from 'node:path';
import { readFile } from 'node:fs/promises';
const PUBLIC_DIR = path.join(import.meta.dirname, '..', 'public');
export async function serveStatic(pathname, res) {
const name = pathname === '/' ? 'index.html' : pathname;
const file = path.join(PUBLIC_DIR, name);
if (!file.startsWith(PUBLIC_DIR + path.sep)) return false;
try {
const content = await readFile(file);
const type = MIME_TYPES[path.extname(file)] ?? 'application/octet-stream';
res.writeHead(200, { 'Content-Type': type });
res.end(content);
return true;
} catch {
return false;
}
}The function returns true if the file was found and sent, and false if it does not exist. No encoding is passed to readFile(): images are bytes, not text.
Wiring it into the server
In src/server.js the server first tries to serve a file and only then the ordinary routes:
// src/server.js
import http from 'node:http';
import { serveStatic } from './static.js';
const server = http.createServer(async (req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && (await serveStatic(pathname, res))) return;
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Page not found');
});
server.listen(3000);http://localhost:3000 shows the purple heading "Notes by Anna": the browser loaded the page and, with a separate request, /style.css.
Common mistake
If the path is built from the address without a check, a visitor can request /../.env and read your passwords — this is called path traversal. That is why serveStatic() has this line:
// src/static.js
if (!file.startsWith(PUBLIC_DIR + path.sep)) return false;After path.join() every .. is already resolved, and if the final path ends up outside public, the file is not served. Never remove this check.