Serving HTML & Static Files
A web server's bread and butter is sending files to the browser — HTML pages, CSS, JavaScript, images. Doing this correctly with raw http means reading files (ideally as streams), setting the right Content-Type, and — most importantly — not letting a crafted URL read files outside your public directory. This page builds a small but safe static file server.
Serving a single HTML page
JS
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
createServer(async (req, res) => {
const html = await readFile(new URL('./index.html', import.meta.url), 'utf8')
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(html)
}).listen(3000)Anchor file paths to the module, not the cwd
Using `new URL('./index.html', import.meta.url)` (or `join(import.meta.dirname, 'index.html')`) reads the file relative to *this script*, not wherever `node` was launched — avoiding the [cwd trap](/nodejs/file-paths). And always send `charset=utf-8` so non-ASCII content renders correctly.
Streaming files instead of buffering
readFile loads the whole file into memory before sending. For large assets, stream it straight to the response so memory stays flat:
JS
import { createReadStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'
createServer(async (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
await pipeline(createReadStream('index.html'), res) // backpressure-safe
}).listen(3000)Content-Type by extension
The browser relies on Content-Type to decide what a file is. Map file extensions to MIME types — get this wrong and CSS won't apply, scripts won't run, images won't show:
JS
import { extname } from 'node:path'
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
}
function contentType(file) {
return MIME[extname(file).toLowerCase()] || 'application/octet-stream'
}Browsers won't apply CSS or run JS served with the wrong type
Modern browsers enforce MIME types: a stylesheet sent as `text/plain` is ignored, and a module script with the wrong type is blocked (especially with `X-Content-Type-Options: nosniff`). The fallback `application/octet-stream` tells the browser "unknown binary — download it." Maintain an accurate extension→type map, or use the `mime-types` npm package.
The security-critical part: path traversal
Never join the request path onto your public dir without validating it
If you do `join(publicDir, req.url)`, a request for `/../../etc/passwd` (or URL-encoded `%2e%2e%2f`) escapes your public folder and serves **arbitrary files from the server** — passwords, source code, secrets. This **path traversal** vulnerability is one of the most common and damaging web bugs. After resolving the path, you must verify it still lives inside the public directory.
A safe static file server
JS
import { createServer } from 'node:http'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { join, resolve, normalize, extname, sep } from 'node:path'
import { pipeline } from 'node:stream/promises'
const PUBLIC = resolve('./public')
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`)
// decodeURIComponent so %2e%2e can't sneak past, then normalize:
let pathname = decodeURIComponent(url.pathname)
if (pathname === '/') pathname = '/index.html'
const target = resolve(join(PUBLIC, normalize(pathname)))
// ✦ The crucial check: target must stay inside PUBLIC
if (target !== PUBLIC && !target.startsWith(PUBLIC + sep)) {
res.writeHead(403).end('Forbidden')
return
}
const info = await stat(target)
if (!info.isFile()) { res.writeHead(404).end('Not Found'); return }
res.writeHead(200, { 'Content-Type': contentType(target) })
await pipeline(createReadStream(target), res)
} catch (err) {
if (err.code === 'ENOENT') res.writeHead(404).end('Not Found')
else { console.error(err); res.writeHead(500).end('Server Error') }
}
}).listen(3000)`decodeURIComponent` then confine — both steps matter
Attackers encode `../` as `%2e%2e%2f` to slip past naive string checks, so decode **before** validating. Then resolve to an absolute path and confirm it starts with your public directory plus a separator (the `+ sep` prevents `/public-secret` from matching `/public`). This is the same [`safeJoin` pattern](/nodejs/file-paths) from the paths page — non-negotiable for any file-serving code.
Add caching for static assets
JS
import { stat } from 'node:fs/promises'
const info = await stat(target)
res.writeHead(200, {
'Content-Type': contentType(target),
'Content-Length': info.size,
'Last-Modified': info.mtime.toUTCString(),
'Cache-Control': 'public, max-age=3600',
})In production, let a CDN or reverse proxy serve static files
Hand-rolled static serving is great for learning and small apps, but a CDN or reverse proxy (nginx, Caddy) serves files faster, handles ranges/compression/caching, and frees Node for dynamic work. In Express, `express.static()` wraps all the safety and caching shown here. Reserve manual serving for when you need custom logic per file.
Next
The other dominant response format — structured data for APIs: [Serving JSON Responses](/nodejs/serving-json).