Learn how express.static serves HTML, CSS, images and JavaScript from a folder, how URLs map to files, and how to set caching and safety options in Express 5.

Bishal Biswas
Self Learner
What you will learn
- What static files are and why you should not write a route for each one
- How to serve a whole folder with
express.static()in one line - How request URLs map to files on disk, and how to add a URL prefix
- Useful options: caching, file extensions, index pages and dotfiles
- Where to place
express.static()relative to your routes
What are static files?
A static file is a file you send to the browser exactly as it is on disk, with no changes. Your CSS stylesheet, a logo image, a favicon, browser JavaScript, fonts and plain HTML pages are all static files.
The opposite is dynamic content, which your code builds for each request, like a JSON list of the current user’s orders.
In the last lessons you used res.sendFile() to send one file from a route. That works for one or two files. But a website might have hundreds of images and scripts. Writing a route for each one would be silly.
Think of a library’s open shelf. You do not ask the librarian for every book. You walk to the shelf and take what you need. express.static() turns a folder into an open shelf: any file inside it can be fetched by its name.
Your first static folder
Create this project structure:
my-site/
app.js
package.json
public/
index.html
about.html
css/
style.css
images/
logo.png
<!-- public/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My Site</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<img src="/images/logo.png" alt="Logo" width="80" />
<h1>Welcome</h1>
<a href="/about.html">About us</a>
</body>
</html>
Now the server. One line does all the work:
// app.js
import express from 'express';
import path from 'node:path';
const app = express();
// Serve every file inside the "public" folder
app.use(express.static(path.join(import.meta.dirname, 'public')));
app.listen(3000, (error) => {
if (error) throw error;
console.log('Listening on http://localhost:3000');
});
Open http://localhost:3000 in your browser. You see the page, styled, with the logo. The browser made four requests (the page, the CSS, the image, and probably a favicon), and express.static() answered all of them.
Note:
import.meta.dirname(Node.js 20.11+) is the folder of the current file. Using it makes the path absolute, so the app works no matter which folder you start it from.express.static('public')also works, but only when you runnodefrom insidemy-site/.
How URLs map to files
express.static() takes the URL path and looks for a file with the same path inside your folder. The folder name itself is not part of the URL.
| Request URL | File on disk |
|---|---|
/ |
public/index.html |
/about.html |
public/about.html |
/css/style.css |
public/css/style.css |
/images/logo.png |
public/images/logo.png |
/public/about.html |
Not found. public is not in the URL. |
/contact.html |
Not found (no such file). Moves on to your routes. |
Two things happen automatically:
- Index files. When the URL points to a folder, Express looks for
index.htmlinside it. That is why/showspublic/index.html. - Content types. Express sets
Content-Typefrom the file extension:.cssbecomestext/css,.pngbecomesimage/png, and so on.
Common mistake: Writing
<link href="/public/css/style.css">in your HTML. The folder name is not part of the URL. Use/css/style.css.
What happens when no file matches
If there is no matching file, express.static() does not send a 404 itself. It simply calls next(), which means “not mine, pass it on”. The request continues to the next handler in your app.
GET /api/products
|
v
+----------------------+
| express.static() | Is there public/api/products? No -> pass it on
+----------------------+
|
v
+----------------------+
| app.get('/api/...') | Yes, I handle this -> sends JSON
+----------------------+
That is why static files and API routes live happily in the same app:
// app.js
app.use(express.static(path.join(import.meta.dirname, 'public')));
app.get('/api/products', (req, res) => {
res.json([{ id: 1, name: 'Lamp' }]);
});
app.use() registers middleware, a function that runs for many requests before your routes. You will learn exactly how this “pass it on” chain works in How the Middleware Stack Works.
Adding a URL prefix
Sometimes you want static files under their own URL, such as /static/... or /assets/.... Pass a mount path as the first argument to app.use():
app.use('/static', express.static(path.join(import.meta.dirname, 'public')));
Now the URL needs the prefix:
| Request URL | File on disk |
|---|---|
/static/css/style.css |
public/css/style.css |
/static/images/logo.png |
public/images/logo.png |
/css/style.css |
Not found |
A prefix makes it obvious which URLs are files and which are routes. It also means express.static() only checks the disk for /static/... requests, not for every API call.
Serving more than one folder
You can call express.static() several times. Express checks the folders in the order you add them, and the first match wins.
app.use(express.static(path.join(import.meta.dirname, 'public')));
app.use(express.static(path.join(import.meta.dirname, 'uploads')));
GET /photo.jpg
1. public/photo.jpg ? not found, try next
2. uploads/photo.jpg ? found -> send it
A common real-world setup puts third-party files under their own prefix:
// serve files from an npm package, e.g. a CSS library, under /vendor
app.use('/vendor', express.static(path.join(import.meta.dirname, 'node_modules', 'some-css-lib', 'dist')));
Useful options
express.static() takes a second argument with options. Here are the ones you will reach for most.
| Option | Default | What it does |
|---|---|---|
maxAge |
0 |
How long browsers may cache files. Number in ms, or a string like '1d'. |
immutable |
false |
Tells browsers the file will never change while cached. Use with maxAge. |
index |
'index.html' |
The file to send for a folder URL. false turns it off. |
extensions |
false |
Extensions to try when a URL has none, e.g. ['html']. |
dotfiles |
'ignore' |
What to do with files whose names start with a dot. |
etag |
true |
Send an ETag header for caching. |
fallthrough |
true |
On “not found”, call next() instead of sending an error. |
setHeaders |
none | A function to add your own headers to each file. |
Clean URLs with extensions
Want /about to work instead of /about.html? Tell Express which extensions to try:
app.use(express.static(path.join(import.meta.dirname, 'public'), {
extensions: ['html'], // /about -> public/about.html
}));
Caching with maxAge
Every time a visitor loads a page, their browser may ask for the CSS and images again. Caching means the browser keeps a copy and reuses it, which makes your site faster and your server less busy.
maxAge sets the Cache-Control header for you:
app.use('/static', express.static(path.join(import.meta.dirname, 'public'), {
maxAge: '7d', // browsers may reuse files for 7 days without asking
}));
curl -I http://localhost:3000/static/css/style.css
HTTP/1.1 200 OK
Cache-Control: public, max-age=604800
Content-Type: text/css; charset=UTF-8
ETag: W/"5a-..."
Last-Modified: Sun, 25 Oct 2026 08:00:00 GMT
curl -I sends a HEAD request: it asks for headers only, not the body.
Warning: A long
maxAgemeans users may keep an oldstyle.cssfor days after you change it. Build tools solve this by adding a fingerprint to file names, likestyle.3f9a1c.css. A new version gets a new name, so it is never stale. Only use long cache times for fingerprinted files, or keepmaxAgeshort.
Even with maxAge: 0, the ETag and Last-Modified headers let browsers ask “has this changed?” and get a quick 304 Not Modified if not.
Dotfiles are hidden by default
A dotfile is a file or folder whose name starts with a dot, like .env or .git. They often hold secrets or settings.
In Express 5, the dotfiles option defaults to 'ignore'. Express acts as if these files do not exist, so a request for /.env gets a 404. This applies to any part of the path that starts with a dot, including folders.
| Value | Behaviour |
|---|---|
'ignore' |
Pretend the file does not exist (404). The Express 5 default. |
'deny' |
Answer 403 Forbidden. |
'allow' |
Serve it like any other file. |
If you need to serve one special dot-folder, such as /.well-known/ for domain verification, mount just that folder on its own with 'allow'. Do not switch the whole public/ folder to 'allow'.
// public/.well-known/security.txt -> /.well-known/security.txt
app.use('/.well-known', express.static(path.join(import.meta.dirname, 'public', '.well-known'), {
dotfiles: 'allow',
}));
Adding custom headers with setHeaders
app.use(express.static(path.join(import.meta.dirname, 'public'), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.pdf')) {
res.setHeader('Content-Disposition', 'attachment'); // download PDFs instead of showing them
}
},
}));
Where to put express.static()
Order matters in Express. Things registered first get the first chance to answer.
| Place it… | Effect |
|---|---|
| Before your routes | Files win. If public/api/users existed, it would hide your /api/users route. Every request checks the disk first. |
| After your routes | Routes win. Only requests no route answered reach the disk. |
Under a prefix like /static |
No overlap at all. Only /static/... requests touch the disk. |
For most beginner projects, placing it first with no prefix is fine and easy. As your app grows, a prefix is the cleanest choice.
Single-page apps: a fallback to index.html
If you build your frontend with React, Vue or similar, the browser handles URLs like /dashboard/settings itself. But if the user refreshes on that URL, the request hits your server, and there is no dashboard/settings file. You need to send index.html for those URLs and let the frontend take over.
// app.js
const publicDir = path.join(import.meta.dirname, 'public');
app.use(express.static(publicDir));
app.get('/api/products', (req, res) => {
res.json([{ id: 1, name: 'Lamp' }]);
});
// Any other GET request: send the app shell
app.get('/*splat', (req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
'/*splat' is the Express 5 named wildcard you learned in Route Parameters and path-to-regexp v8 Syntax. It must come after your API routes, or it would swallow them. The root / is already handled by express.static() serving index.html.
Tip: With this fallback, a wrong API URL like
/api/prodctswould get HTML instead of a JSON 404. You will learn to give API routes their own “not found” handler in Not Found and Fallback Handling.
Common mistakes
Serving your project root. express.static(import.meta.dirname) would expose app.js, package.json and everything else. Always serve a dedicated folder like public/.
Including the folder name in URLs. The URL is /css/style.css, not /public/css/style.css.
Using a relative folder path. express.static('public') breaks when you start Node from another folder. Use path.join(import.meta.dirname, 'public').
Putting secrets in public/. Anything in that folder is downloadable by anyone. Keep .env files and config outside it.
Long cache times on files that change. Visitors keep seeing the old version. Use short maxAge values unless file names include a fingerprint.
Key takeaways
express.static(folder)serves every file in a folder with one line of code.- The folder name is not part of the URL. Add a prefix with
app.use('/static', express.static(...)). - When no file matches, it calls
next()and your routes get a turn. maxAge,extensionsandsetHeaderscontrol caching, clean URLs and extra headers.- In Express 5,
dotfilesdefaults to'ignore', so.env-style files return 404. - Use an absolute path, and never serve your whole project folder.
Practice
- Build the
my-siteexample with anindex.html, a CSS file and an image. Check the Network tab in your browser to see each file load. - Move the static folder under
/assets. Update your HTML links so the page still works. - Add
extensions: ['html']and confirm that/aboutshowsabout.html. - Create a file
public/.secret.txtand request it. What status code do you get? Changedotfilesto'deny'and try again.
What’s next
Your app is growing, and soon it will need settings like port numbers and secret keys. Learn to manage them in Environment Variables and Configuration.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready