Series: Pure Node.js — Zero Dependencies

Post #3: Serving Static Files (HTML, CSS, JS)

📅 March 2026 ⏱ 8 min read 🏷 Node.js, fs, Static Files, Security

📚 Pure Node.js Series — Zero Dependencies

#1: Build an HTTP Server From Scratch #2: Manual Routing — No Express Needed → #3: Serving Static Files (HTML, CSS, JS) (you are here) #4: Handling Forms & POST Requests #5: JSON Files as a Database #6: HTML Templating with String Interpolation #7: Vanilla JS Charts & Dynamic UI #8: WebSockets From Scratch #9: Chat Rooms & Broadcast Messages #10: Sessions & Cookies Without Packages #11: File Uploads & Image Serving #12: Deploy Pure Node.js to a VPS

Static Files in Node.js — No serve-static Needed

When Express serves your public/ folder, it uses a package called serve-static under the hood. That package reads files from disk and sends them with the right headers. We can do exactly the same thing with Node's built-in fs and path modules — and learn a lot about security in the process.

What we're building: A static.js middleware that reads files from a public/ directory, serves them with correct MIME types, adds caching headers using ETag and Cache-Control, and prevents directory traversal attacks.

Reading a File with fs.readFile

The most basic version — just read a file and send it:

const http = require('http');
const fs   = require('fs');
const path = require('path');

http.createServer((req, res) => {
  // Serve index.html for every request (for now)
  const filePath = path.join(__dirname, 'public', 'index.html');

  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.writeHead(404);
      res.end('Not found');
      return;
    }
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end(data);
  });
}).listen(3000);

This works, but it uses the callback style. Let's use fs.promises for cleaner async/await code throughout:

const fs = require('fs').promises; // or: require('fs/promises') in Node 14+

async function serveFile(res, filePath, contentType) {
  try {
    const data = await fs.readFile(filePath);
    res.writeHead(200, { 'Content-Type': contentType });
    res.end(data);
  } catch (err) {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('404 — File not found');
  }
}

MIME Types — Telling the Browser What You're Sending

Without the right Content-Type header, browsers won't know how to handle your files. CSS won't be applied, JavaScript won't execute. We need a MIME type map:

// mime.js
const MIME_TYPES = {
  '.html': 'text/html; charset=utf-8',
  '.css':  'text/css; charset=utf-8',
  '.js':   'application/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.png':  'image/png',
  '.jpg':  'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif':  'image/gif',
  '.svg':  'image/svg+xml',
  '.ico':  'image/x-icon',
  '.woff': 'font/woff',
  '.woff2':'font/woff2',
  '.txt':  'text/plain; charset=utf-8',
  '.pdf':  'application/pdf',
};

function getMimeType(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  return MIME_TYPES[ext] || 'application/octet-stream';
}

module.exports = { getMimeType };
application/octet-stream is the default for unknown file types — browsers will download the file rather than display it. That's the safest fallback behavior.

Directory Traversal Attacks — The Critical Security Issue

This is the most important part of serving static files. A malicious user might request:

GET /../../etc/passwd HTTP/1.1
GET /../server.js HTTP/1.1
GET /%2e%2e%2fetc%2fpasswd HTTP/1.1  (URL-encoded)

If you naively join public/ + req.url, you'll serve files outside your intended directory. The fix is to resolve the path and verify it still starts with your public directory:

const path = require('path');

const PUBLIC_DIR = path.resolve(__dirname, 'public');

function getSafePath(requestUrl) {
  // Decode URL encoding (%2e%2e → ..)
  const decoded = decodeURIComponent(requestUrl);

  // Remove query string
  const pathname = decoded.split('?')[0];

  // Join with public dir and resolve (normalizes .. and .)
  const resolved = path.resolve(PUBLIC_DIR, '.' + pathname);

  // CRITICAL: verify the resolved path is inside PUBLIC_DIR
  if (!resolved.startsWith(PUBLIC_DIR + path.sep) && resolved !== PUBLIC_DIR) {
    return null; // Traversal attempt detected
  }

  return resolved;
}
Never skip this check. Directory traversal is a real vulnerability. Always resolve the path and verify it stays within your public directory. The path.resolve() call handles all the .. normalization.

Building the Full Static File Server

Now let's put it all together into a reusable static.js module:

// static.js
'use strict';

const fs   = require('fs').promises;
const path = require('path');

const MIME_TYPES = {
  '.html': 'text/html; charset=utf-8',
  '.css':  'text/css; charset=utf-8',
  '.js':   'application/javascript; charset=utf-8',
  '.json': 'application/json',
  '.png':  'image/png',
  '.jpg':  'image/jpeg',
  '.svg':  'image/svg+xml',
  '.ico':  'image/x-icon',
  '.txt':  'text/plain',
};

function createStaticHandler(publicDir) {
  const root = path.resolve(publicDir);

  return async function staticHandler(req, res) {
    // Only serve GET requests
    if (req.method !== 'GET' && req.method !== 'HEAD') return false;

    // Decode and clean the URL path
    let pathname;
    try {
      pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
    } catch {
      return false;
    }

    // Default to index.html for directory requests
    if (pathname.endsWith('/')) pathname += 'index.html';

    // Security: resolve and verify the path
    const filePath = path.resolve(root, '.' + pathname);
    if (!filePath.startsWith(root + path.sep) && filePath !== root) {
      res.writeHead(403, { 'Content-Type': 'text/plain' });
      res.end('403 Forbidden');
      return true;
    }

    // Read the file
    let data, stat;
    try {
      [data, stat] = await Promise.all([
        fs.readFile(filePath),
        fs.stat(filePath),
      ]);
    } catch {
      return false; // File not found — let other routes handle it
    }

    // Build ETag from file size + modification time
    const etag = `"${stat.size}-${stat.mtime.getTime()}"`;

    // Cache validation — 304 Not Modified
    if (req.headers['if-none-match'] === etag) {
      res.writeHead(304);
      res.end();
      return true;
    }

    const ext         = path.extname(filePath).toLowerCase();
    const contentType = MIME_TYPES[ext] || 'application/octet-stream';

    res.writeHead(200, {
      'Content-Type':   contentType,
      'Content-Length': stat.size,
      'ETag':           etag,
      'Cache-Control':  'public, max-age=3600', // 1 hour
      'Last-Modified':  stat.mtime.toUTCString(),
    });

    // HEAD requests: headers only, no body
    if (req.method === 'HEAD') {
      res.end();
    } else {
      res.end(data);
    }
    return true; // Handled
  };
}

module.exports = { createStaticHandler };

Integrating with Our Router

// server.js
const http   = require('http');
const Router = require('./router');
const { createStaticHandler } = require('./static');

const router = new Router();
const serveStatic = createStaticHandler('./public');

// API routes
router.get('/api/todos', (req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ todos: [] }));
});

const server = http.createServer(async (req, res) => {
  // Try static files first
  const handled = await serveStatic(req, res);
  if (handled) return;

  // Fall through to API router
  router.handle(req, res);
});

server.listen(3000, () => console.log('http://localhost:3000'));

Understanding ETag and Caching

The ETag header is a fingerprint of a file's content. The browser caches it and sends If-None-Match: "etag-value" on the next request. If the ETag still matches, we return 304 Not Modified with no body — saving bandwidth.

// First request:
// Server → ETag: "1024-1710000000000"
// Browser caches the file and the ETag

// Second request (browser):
// Browser → If-None-Match: "1024-1710000000000"
// Server: ETag matches → 304 Not Modified (no body sent)
// Browser: uses cached version ✓

// After file changes:
// stat.mtime changes → new ETag → 200 OK + new file content
Cache-Control strategy: Use max-age=31536000, immutable for versioned assets (e.g., app.v2.js) and no-cache for HTML files that should always be validated. The ETag handles revalidation efficiently.

Streaming Large Files

For large files (images, PDFs), loading the entire file into memory with readFile is wasteful. Use streams instead:

const fs = require('fs'); // Note: NOT .promises — we need createReadStream

function streamFile(res, filePath, contentType, stat) {
  const etag = `"${stat.size}-${stat.mtime.getTime()}"`;

  res.writeHead(200, {
    'Content-Type':   contentType,
    'Content-Length': stat.size,
    'ETag':           etag,
    'Cache-Control':  'public, max-age=86400',
  });

  // Stream directly from disk to response — no memory buffering
  const stream = fs.createReadStream(filePath);
  stream.pipe(res);

  stream.on('error', () => {
    res.destroy(); // Close connection on error
  });
}
Key takeaways:
  • Use fs.promises.readFile() or createReadStream() for streaming
  • Always map file extensions to MIME types — browsers require them
  • Use path.resolve() + prefix check to prevent directory traversal
  • ETag + 304 Not Modified saves bandwidth for repeat visitors
  • Set Cache-Control headers to let browsers cache assets

What's Next

In Post #4, we'll handle form submissions and POST requests — reading the request body chunk by chunk, parsing URL-encoded and JSON data, and implementing the redirect-after-POST pattern.

Building along? Share your code on X/Twitter or GitHub.