Skip to content

Serving Static Files in Node.js

Serving static files, images, stylesheets, client-side JavaScript, is a common server responsibility even in API-focused apps. This lesson shows how to serve them safely with plain Node.js.

Serving a File From Disk

Serving a static file means reading it from disk and writing its contents to the response, with an appropriate Content-Type header so the browser knows how to interpret it (an image, a stylesheet, plain text, and so on).

Streaming the file with fs.createReadStream() and .pipe() (rather than reading the whole file into memory first) keeps memory usage low, especially important for large assets like videos or bundled JavaScript files.

import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';

const publicDir = path.join(import.meta.dirname, 'public');

const server = http.createServer((req, res) => {
  const filePath = path.join(publicDir, req.url === '/' ? 'index.html' : req.url);

  fs.createReadStream(filePath)
    .on('error', () => {
      res.statusCode = 404;
      res.end('Not found');
    })
    .pipe(res);
});

server.listen(3000);

Piping the file stream directly into res streams it to the client chunk by chunk, and the 'error' handler catches missing files cleanly instead of crashing.

Setting the Correct Content-Type

const contentTypes = {
  '.html': 'text/html',
  '.css': 'text/css',
  '.js': 'application/javascript',
  '.json': 'application/json',
  '.png': 'image/png',
};

res.setHeader('Content-Type', contentTypes[ext] || 'application/octet-stream');
  • Browsers rely on Content-Type to decide how to render or execute a file.
  • path.extname() gives you the file extension needed to look up the right MIME type.
  • 'application/octet-stream' is a safe fallback for unknown file types (triggers a download).
  • Package managers offer libraries like mime-types if you don't want to maintain your own lookup table.

Static Files Cheatsheet

Common file extensions and their correct Content-Type values.

Extension Content-Type
.html text/html
.css text/css
.js application/javascript
.json application/json
.png / .jpg / .svg image/png, image/jpeg, image/svg+xml
.pdf application/pdf

Preventing Path Traversal Attacks

A naive static file server that joins user-supplied paths directly can be tricked into reading files outside its intended directory using ../ segments (a "path traversal" attack), potentially exposing sensitive files elsewhere on the server.

import path from 'node:path';

function resolveSafePath(publicDir, requestedPath) {
  const resolved = path.join(publicDir, requestedPath);

  if (!resolved.startsWith(publicDir)) {
    throw new Error('Path traversal attempt blocked');
  }

  return resolved;
}

Always verify the resolved path still starts with your intended public directory before reading the file, this single check closes off an entire class of vulnerabilities.

Caching Headers for Static Assets

Production servers typically add caching headers (Cache-Control, ETag) to static assets so browsers avoid re-downloading unchanged files on every visit, a meaningful performance win for CSS, JavaScript, and images.

  • Cache-Control: max-age=31536000, immutable for versioned/fingerprinted assets.
  • Shorter cache lifetimes (or none) for frequently-changing files like index.html.
  • In production, this is usually handled by Express's express.static() or a CDN rather than by hand.

Common Mistakes

  • Joining request paths directly into a file path without validating against path traversal (../../etc/passwd).
  • Forgetting to set Content-Type, causing browsers to guess incorrectly or download files instead of rendering them.
  • Reading entire large files into memory with fs.readFile() instead of streaming them.
  • Serving files without any caching headers, forcing browsers to re-download unchanged assets repeatedly.

Key Takeaways

  • Serving static files means reading them from disk and setting a matching Content-Type header.
  • Streaming with fs.createReadStream().pipe(res) avoids loading entire files into memory.
  • Always validate resolved file paths to prevent path traversal attacks.
  • Caching headers (Cache-Control, ETag) reduce redundant downloads for unchanged assets.

Pro Tip

Unless you have a specific reason to hand-roll static file serving, use express.static() (covered in the Express Static Files lesson) or a CDN in production, both already handle path traversal protection, caching headers, and range requests correctly.