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 manualfs.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:
| URL | File served |
|---|---|
http://localhost:3000/ | public/index.html (auto index) |
http://localhost:3000/index.html | public/index.html |
http://localhost:3000/styles.css | public/styles.css |
http://localhost:3000/script.js | public/script.js |
http://localhost:3000/images/logo.png | public/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:
| Extension | Content-Type |
|---|---|
.html | text/html |
.css | text/css |
.js | application/javascript |
.json | application/json |
.png | image/png |
.jpg / .jpeg | image/jpeg |
.svg | image/svg+xml |
.gif | image/gif |
.ico | image/x-icon |
.pdf | application/pdf |
.woff2 | font/woff2 |
.mp4 | video/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:
| URL | File served |
|---|---|
http://localhost:3000/static/styles.css | public/styles.css |
http://localhost:3000/static/images/logo.png | public/images/logo.png |
http://localhost:3000/styles.css | Not found (no static middleware on root) |
Why use a virtual prefix?
- Clarity — URLs starting with
/static/are obviously static assets - Caching rules — you can set different cache headers for
/static/*vs API routes - CDN integration — easy to route
/static/*through a CDN - 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
| Value | Duration |
|---|---|
0 | No caching |
'1h' | 1 hour |
'1d' | 1 day |
'7d' | 7 days |
'30d' | 30 days |
'1y' | 1 year |
86400000 | 1 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 value | Behavior |
|---|---|
'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
express.static(dir)serves files from a directory — no manualfs.readFile()needed.- Always use
path.join(__dirname, 'public')for absolute paths — relative paths break when cwd differs. - Virtual path prefixes (
/assets,/static) keep URLs organized and avoid route collisions. - Order matters — static middleware checked first serves that version of a file. API routes should be declared before the SPA fallback.
- Set
maxAgefor browser caching; use content-hashed filenames +immutablefor aggressive caching in production. express.staticdenies dotfiles by default — do not put.envor sensitive files in the public directory.- For SPAs, serve
index.htmlas a wildcard fallback after API routes and static files.
Explain-It Challenge
Explain without notes:
- What happens when you request
/styles.cssandexpress.static('public')is configured? Walk through the resolution. - Why should you use
path.join(__dirname, 'public')instead of just'public'? - Describe the three-layer pattern for serving a SPA with an Express backend: API routes, static files, fallback.
- What does the
maxAgeoption do, and why would you set it to'1y'for hashed assets?
Navigation: <- 3.4.e — HTTP Methods and Request Body | 3.4 Overview ->