Episode 3 — NodeJS MongoDB Backend Architecture / 3.4 — Express JS

3.4.f — Serving Static Files

In one sentence: express.static() is Express's built-in middleware for serving HTML, CSS, JavaScript, images, and other files directly from a folder — eliminating the need for manual fs.readFile() calls and giving you control over caching, virtual paths, and security for production-ready static file serving.

Navigation: <- 3.4.e — HTTP Methods and Request Body | 3.4 Overview ->


1. express.static(path) — built-in middleware for static files

express.static() is one of the few built-in middleware functions in Express. It serves files from a specified directory:

const express = require('express');
const path = require('path');
const app = express();

// Serve files from the 'public' directory
app.use(express.static('public'));

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

What this enables

If your project has this structure:

my-app/
  public/
    index.html
    styles.css
    script.js
    images/
      logo.png
  server.js

Then these URLs work automatically:

URLFile served
http://localhost:3000/public/index.html (auto index)
http://localhost:3000/index.htmlpublic/index.html
http://localhost:3000/styles.csspublic/styles.css
http://localhost:3000/script.jspublic/script.js
http://localhost:3000/images/logo.pngpublic/images/logo.png

Notice: The public/ folder name does not appear in the URL. express.static('public') makes everything inside public/ available at the root.


2. app.use(express.static('public')) — serving from a public folder

Basic setup

const express = require('express');
const app = express();

// Serve static files from 'public' directory
app.use(express.static('public'));

// API routes (still work alongside static files)
app.get('/api/users', (req, res) => {
  res.json([{ id: 1, name: 'Alice' }]);
});

app.listen(3000);

How Express resolves files

When a request comes in, Express checks the static folder first (if express.static is placed first):

Request: GET /styles.css
  1. Express checks public/styles.css
  2. File exists → serve it with correct Content-Type
  3. File does NOT exist → pass to next middleware/route

File to Content-Type mapping

Express auto-detects Content-Type based on file extension:

ExtensionContent-Type
.htmltext/html
.csstext/css
.jsapplication/javascript
.jsonapplication/json
.pngimage/png
.jpg / .jpegimage/jpeg
.svgimage/svg+xml
.gifimage/gif
.icoimage/x-icon
.pdfapplication/pdf
.woff2font/woff2
.mp4video/mp4

3. Virtual path prefix: /static

You can mount static files under a virtual path that does not exist in the file system:

// Files in 'public/' are served at '/static/*'
app.use('/static', express.static('public'));

Now the URLs change:

URLFile served
http://localhost:3000/static/styles.csspublic/styles.css
http://localhost:3000/static/images/logo.pngpublic/images/logo.png
http://localhost:3000/styles.cssNot found (no static middleware on root)

Why use a virtual prefix?

  1. Clarity — URLs starting with /static/ are obviously static assets
  2. Caching rules — you can set different cache headers for /static/* vs API routes
  3. CDN integration — easy to route /static/* through a CDN
  4. Avoids collisions — static file names won't conflict with API routes
// Common pattern in production
app.use('/assets', express.static('public'));
app.use('/uploads', express.static('uploads'));

// API routes on /api/*
app.get('/api/users', (req, res) => res.json([]));

4. Serving HTML, CSS, JS, images

Complete example with all file types

project/
  public/
    index.html
    css/
      styles.css
    js/
      app.js
    images/
      hero.jpg
      logo.svg
    fonts/
      inter.woff2
  server.js
// server.js
const express = require('express');
const path = require('path');
const app = express();

app.use(express.static(path.join(__dirname, 'public')));

app.listen(3000, () => {
  console.log('Serving at http://localhost:3000');
});
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>My App</title>
  <link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
  <img src="/images/logo.svg" alt="Logo" />
  <h1>Welcome</h1>
  <img src="/images/hero.jpg" alt="Hero" />
  <script src="/js/app.js"></script>
</body>
</html>

All references use absolute paths starting with /. Express resolves them against the static directory.


5. path.join(__dirname, 'public') — absolute path for reliability

Problem: The relative path 'public' in express.static('public') is resolved relative to the current working directory (where you ran node), not the file's location:

# This works:
cd /project && node server.js

# This BREAKS (cwd is root, no 'public' folder here):
node /project/server.js

Solution: Always use path.join(__dirname, 'public') for an absolute path:

const path = require('path');

// WRONG — relative, depends on cwd
app.use(express.static('public'));

// CORRECT — absolute, always reliable
app.use(express.static(path.join(__dirname, 'public')));

__dirname is the directory of the current file, not the working directory.

With ES modules

// ES modules don't have __dirname — create it manually
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

app.use(express.static(join(__dirname, 'public')));

6. Multiple static directories

You can serve files from multiple folders. Express tries them in order:

// Check 'public' first, then 'uploads', then 'assets'
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.static(path.join(__dirname, 'uploads')));
app.use(express.static(path.join(__dirname, 'assets')));

Order matters

If both public/image.png and uploads/image.png exist, the one from public/ is served because it is checked first.

Practical example: separate user uploads from app assets

// App assets (CSS, JS, images baked into the app)
app.use('/assets', express.static(path.join(__dirname, 'public')));

// User-uploaded files (avatars, documents)
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));

// URLs:
// /assets/css/styles.css  → public/css/styles.css
// /uploads/avatars/alice.jpg → uploads/avatars/alice.jpg

7. Cache control with static files

express.static supports cache control headers to improve performance:

// Set max-age for browser caching
app.use(express.static(path.join(__dirname, 'public'), {
  maxAge: '1d',  // Cache for 1 day
}));

Cache strategies

// Development — no caching
app.use(express.static(path.join(__dirname, 'public'), {
  maxAge: 0,
  etag: true,  // Still use ETags for conditional requests
}));

// Production — aggressive caching for hashed assets
app.use('/assets', express.static(path.join(__dirname, 'dist'), {
  maxAge: '1y',     // 1 year (files have content hashes in names)
  immutable: true,  // Tell browser this file will NEVER change
}));

// Production — moderate caching for non-hashed files
app.use(express.static(path.join(__dirname, 'public'), {
  maxAge: '1h',     // 1 hour, then revalidate
}));

How cache-busting works

Modern build tools (Webpack, Vite) add content hashes to filenames:

styles.abc123.css     ← hash changes when content changes
app.def456.js         ← new hash = new URL = browser fetches fresh copy

With hashed filenames, you can safely set maxAge: '1y' because a new version gets a new URL.

maxAge format

ValueDuration
0No caching
'1h'1 hour
'1d'1 day
'7d'7 days
'30d'30 days
'1y'1 year
864000001 day (in milliseconds)

8. Security considerations

Do not expose sensitive files

project/
  public/          ← served
    index.html
    styles.css
  config/          ← NOT served (not in static path)
    database.js
  .env             ← NOT served
  server.js        ← NOT served

Only files inside the directory passed to express.static() are accessible. But be careful about what you put there:

public/
  index.html       ← OK
  .env             ← DANGEROUS if put here
  backup.sql       ← DANGEROUS if put here
  .htaccess        ← Not needed (Apache-specific)

Dot files (hidden files)

By default, express.static denies access to dotfiles (.env, .git, etc.):

app.use(express.static(path.join(__dirname, 'public'), {
  dotfiles: 'deny',    // Default: deny access to dotfiles
  // Options: 'allow', 'deny', 'ignore'
}));
dotfiles valueBehavior
'deny'Returns 403 Forbidden
'ignore'Pretends file does not exist (404)
'allow'Serves dotfiles normally

Disable directory listing

express.static does not show directory listings by default. If someone requests /images/ (a directory), they get the index.html inside it (if it exists) or a 404/next middleware.

Remove X-Powered-By header

// Don't tell attackers you're using Express
app.disable('x-powered-by');

// Or use helmet for comprehensive security headers
const helmet = require('helmet');
app.use(helmet());

9. Serving SPA (single-page app) with fallback

Single-page applications (React, Vue, Angular) use client-side routing. The server must serve index.html for all non-API routes:

const express = require('express');
const path = require('path');
const app = express();

// 1. API routes FIRST
app.get('/api/users', (req, res) => {
  res.json([{ id: 1, name: 'Alice' }]);
});

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

// 2. Serve static files (CSS, JS, images from the SPA build)
app.use(express.static(path.join(__dirname, 'client', 'build')));

// 3. SPA fallback — serve index.html for ALL other routes
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'client', 'build', 'index.html'));
});

app.listen(3000);

How this works

GET /api/users       → API route responds with JSON
GET /styles.css      → express.static serves the file
GET /about           → No static file, no API route → fallback sends index.html
GET /dashboard/stats → Fallback → index.html → React Router handles it

More robust SPA fallback

// Only fallback for GET requests that accept HTML
app.get('*', (req, res, next) => {
  // Don't serve index.html for API routes that weren't matched
  if (req.path.startsWith('/api/')) {
    return res.status(404).json({ error: 'API route not found' });
  }

  res.sendFile(path.join(__dirname, 'client', 'build', 'index.html'));
});

10. express.static options reference

app.use(express.static(path.join(__dirname, 'public'), {
  dotfiles: 'deny',       // How to handle dotfiles: 'allow', 'deny', 'ignore'
  etag: true,             // Enable ETag generation (default: true)
  extensions: false,       // File extension fallbacks: ['html', 'htm']
  fallthrough: true,       // Pass to next middleware if file not found (default: true)
  immutable: false,        // Set immutable directive in Cache-Control
  index: 'index.html',    // Default file for directories (false to disable)
  lastModified: true,      // Set Last-Modified header (default: true)
  maxAge: 0,              // Cache max-age in ms or string ('1d', '1h')
  redirect: true,          // Redirect /dir to /dir/ (default: true)
  setHeaders: undefined,   // Function to set custom headers
}));

Useful option examples

// Disable auto-index (don't serve index.html for directories)
app.use(express.static('public', {
  index: false,
}));

// Try .html extension if no extension in URL
// /about → tries /about.html
app.use(express.static('public', {
  extensions: ['html', 'htm'],
}));

// Custom headers per file
app.use(express.static('public', {
  setHeaders: (res, filePath, stat) => {
    // Set CORS for font files
    if (filePath.endsWith('.woff2') || filePath.endsWith('.woff')) {
      res.set('Access-Control-Allow-Origin', '*');
    }
    // No-cache for HTML files
    if (filePath.endsWith('.html')) {
      res.set('Cache-Control', 'no-cache');
    }
    // Long cache for hashed assets
    if (/\.[a-f0-9]{8,}\.(js|css)$/.test(filePath)) {
      res.set('Cache-Control', 'public, max-age=31536000, immutable');
    }
  },
}));

fallthrough option

// fallthrough: true (default) — if file not found, call next()
app.use(express.static('public', { fallthrough: true }));
// Request continues to next middleware/route if file doesn't exist

// fallthrough: false — if file not found, send 404 immediately
app.use(express.static('public', { fallthrough: false }));
// Returns 404 for missing files without reaching other middleware

11. Complete production example

const express = require('express');
const path = require('path');
const app = express();

// Security
app.disable('x-powered-by');

// Parse request bodies
app.use(express.json());

// Serve static assets with caching
app.use('/assets', express.static(path.join(__dirname, 'public'), {
  maxAge: '7d',
  etag: true,
  dotfiles: 'deny',
  setHeaders: (res, filePath) => {
    // Immutable cache for hashed files
    if (/\.[a-f0-9]{8}\.(js|css)$/.test(filePath)) {
      res.set('Cache-Control', 'public, max-age=31536000, immutable');
    }
  },
}));

// Serve user uploads (shorter cache, may change)
app.use('/uploads', express.static(path.join(__dirname, 'uploads'), {
  maxAge: '1h',
  dotfiles: 'deny',
}));

// API routes
app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/api/products', (req, res) => {
  res.json({ data: [] });
});

// SPA fallback
app.get('*', (req, res, next) => {
  if (req.path.startsWith('/api/')) {
    return res.status(404).json({ error: 'API endpoint not found' });
  }
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

// Error handler
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

12. Key takeaways

  1. express.static(dir) serves files from a directory — no manual fs.readFile() needed.
  2. Always use path.join(__dirname, 'public') for absolute paths — relative paths break when cwd differs.
  3. Virtual path prefixes (/assets, /static) keep URLs organized and avoid route collisions.
  4. Order matters — static middleware checked first serves that version of a file. API routes should be declared before the SPA fallback.
  5. Set maxAge for browser caching; use content-hashed filenames + immutable for aggressive caching in production.
  6. express.static denies dotfiles by default — do not put .env or sensitive files in the public directory.
  7. For SPAs, serve index.html as a wildcard fallback after API routes and static files.

Explain-It Challenge

Explain without notes:

  1. What happens when you request /styles.css and express.static('public') is configured? Walk through the resolution.
  2. Why should you use path.join(__dirname, 'public') instead of just 'public'?
  3. Describe the three-layer pattern for serving a SPA with an Express backend: API routes, static files, fallback.
  4. What does the maxAge option do, and why would you set it to '1y' for hashed assets?

Navigation: <- 3.4.e — HTTP Methods and Request Body | 3.4 Overview ->