Static Files and Templates in Express
Most real web applications are a mix of two things: files that never change per-request (CSS, client-side JavaScript, images, fonts) and pages that are generated on the fly from data (a product list, a user profile, search results). Express has a built-in way to hand off the first kind directly from disk, and a pluggable “view engine” system for the second kind, where a template file is combined with data to produce the final HTML. This lesson covers both: serving a public folder of assets safely and efficiently, and rendering dynamic pages with a template engine (EJS) so you are not concatenating HTML strings by hand.
Overview: How Express Serves Files and Renders Templates
Static file serving in Express is handled by express.static(), which is Express’s built-in wrapper around the serve-static middleware. You give it a root directory on disk, and it returns a middleware function you install with app.use(). On every incoming request, that middleware checks whether the request’s URL path maps to an existing, readable file inside the root directory. If it does, Express streams the file back to the client, sets the Content-Type header by guessing the MIME type from the file extension, sets caching headers (ETag, Last-Modified, optionally Cache-Control), and ends the response there — no route handler ever runs. If the path does not match a file, the middleware calls next() and the request continues down the middleware/route stack as if express.static() were not there at all. This “handle it or fall through” behavior is exactly why where you register express.static() matters: middleware order is request-handling order.
Under the hood, express.static() also supports conditional GET requests: if the browser sends If-None-Match or If-Modified-Since and the file has not changed, Express responds with 304 Not Modified and no body, saving bandwidth. This is one reason to prefer express.static() over manually reading files with fs.readFile() and piping them into res.end() — you would have to reimplement caching, range requests, and MIME detection yourself.
Template engines solve the other half of the problem: HTML that depends on data. You register an engine with app.set("view engine", "ejs") and tell Express where template files live with app.set("views", ...). When a route calls res.render("products", { products }), Express looks for views/products.ejs, compiles it (the compiled function is cached automatically once view cache is enabled, which happens by default when NODE_ENV=production), executes it with the data you passed as local variables, and sends the resulting HTML string as the response body with the correct Content-Type: text/html header. Static files and templates are not mutually exclusive — a rendered EJS page routinely links to CSS and JavaScript that are themselves served by express.static().
Syntax
The general form for serving files:
app.use([mountPath,] express.static(root[, options]));
| Part | Meaning |
|---|---|
mountPath |
Optional URL prefix (e.g. "/assets"). Files are served under this prefix even though the folder name on disk can be different. |
root |
Absolute (recommended) path to the directory whose contents should be served. |
options.index |
Filename served for a directory request, default "index.html". Set to false to disable. |
options.maxAge |
Sets Cache-Control: max-age. Accepts milliseconds or a string like "1d". |
options.dotfiles |
"ignore" | "allow" | "deny" — how to treat files/folders starting with a dot. Default "ignore". |
options.etag |
Whether to generate ETag headers for caching validation. Default true. |
options.fallthrough |
If true (default), a missing file calls next() instead of erroring, letting later routes handle it. |
options.setHeaders |
A function (res, filePath, stat) to set custom response headers per file. |
The general form for rendering a template:
app.set("view engine", "ejs");
app.set("views", viewsDirectory);
app.get(route, (req, res) => {
res.render(viewName, locals);
});
view engine— the template file extension/engine Express should use when you callres.render()without an explicit extension.views— directory (or array of directories) Express searches for view files.viewName— the template filename without extension, relative to theviewsdirectory.locals— a plain object; each key becomes a variable available inside the template.
Examples
Example 1: Serving a public folder
Create a public folder next to your server file containing index.html, style.css, and any images. One line of middleware exposes the whole folder:
import express from "express";
import path from "node:path";
const app = express();
app.use(express.static(path.join(import.meta.dirname, "public")));
app.listen(3000, () => {
console.log("Server running at http://localhost:3000");
});
Output:
Server running at http://localhost:3000
With this in place, a GET to / automatically returns public/index.html (the default index option), a GET to /style.css returns public/style.css with Content-Type: text/css, and a GET to /img/logo.png returns public/img/logo.png. Note the use of path.join(import.meta.dirname, "public"): since this file is an ES module, import.meta.dirname (Node 20.11+) gives the absolute directory of the current file, so the static root resolves correctly no matter what directory you run node app.js from.
Example 2: Mount paths, multiple directories, and caching options
You can mount static middleware under a URL prefix, register more than one static directory, and pass caching options:
import express from "express";
import path from "node:path";
const app = express();
app.use("/assets", express.static(path.join(import.meta.dirname, "public"), {
maxAge: "1d",
index: false,
}));
app.use(express.static(path.join(import.meta.dirname, "uploads")));
app.get("/", (req, res) => {
res.send("Static demo running");
});
app.listen(3000, () => {
console.log("Server running at http://localhost:3000");
});
Output:
Server running at http://localhost:3000
Here, public/logo.png on disk becomes reachable at /assets/logo.png in the URL — the folder name and the URL prefix do not have to match. The maxAge: "1d" option tells browsers to cache those responses for a day via Cache-Control, and index: false stops Express from auto-serving an index.html for directory requests under /assets. The second app.use(express.static(...)) has no prefix, so uploads/report.pdf is served at /report.pdf. Express checks static middlewares in the order they were registered, then falls through to the / route handler for anything neither directory contains.
Example 3: Rendering dynamic pages with EJS alongside static assets
Install Express and the EJS template engine:
npm install express
npm install ejs
Set up the view engine and a route that renders data into a template:
import express from "express";
import path from "node:path";
const app = express();
app.set("view engine", "ejs");
app.set("views", path.join(import.meta.dirname, "views"));
app.use(express.static(path.join(import.meta.dirname, "public")));
const products = [
{ id: 1, name: "Wireless Mouse", price: 24.99 },
{ id: 2, name: "Mechanical Keyboard", price: 89.99 },
];
app.get("/products", (req, res) => {
res.render("products", { products });
});
app.listen(3000, () => {
console.log("Server running at http://localhost:3000");
});
And the template at views/products.ejs:
<!DOCTYPE html>
<html>
<head>
<title>Products</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<h1>Products</h1>
<ul>
<% products.forEach(function(product) { %>
<li><%= product.name %> - $<%= product.price.toFixed(2) %></li>
<% }); %>
</ul>
</body>
</html>
Output: visiting /products returns a rendered HTML page listing “Wireless Mouse – $24.99” and “Mechanical Keyboard – $89.99” inside a bulleted list, and the page’s <link> tag pulls /style.css straight from the same public folder served in the earlier examples. This is the normal shape of an Express app: express.static() handles the assets, res.render() handles the data-driven HTML, and both live in the same middleware stack.
How It Works Step by Step
- A request arrives, e.g.
GET /style.css. Express walks the middleware stack in registration order. - It reaches
express.static(). The middleware resolvesstyle.cssagainst its root directory, confirms the file exists and is not a directory, and reads it (streamed, not loaded into memory as one string). - It sets
Content-Type,Content-Length,ETag/Last-Modified, and any configuredCache-Control, then pipes the file into the response and callsres.end()implicitly. No later middleware or route ever sees this request. - Now consider
GET /products.express.static()finds no matching file, so it callsnext()and Express keeps walking the stack. - Express reaches the
app.get("/products", ...)route, which matches, and the handler runs synchronously up tores.render("products", { products }). res.render()looks upproducts.ejsin the configuredviewsdirectory, compiles the template into a JavaScript function (cached after the first compile when view caching is on), and invokes it withproductsexposed as a template-local variable.- The compiled function returns an HTML string, which Express sends with
res.send()semantics: correctContent-Type: text/htmlheader andContent-Lengthset automatically.
Common Mistakes
1. Using __dirname in an ES module
__dirname only exists in CommonJS. In a file using import/export, referencing it throws.
import express from "express";
const app = express();
app.use(express.static(__dirname + "/public"));
app.listen(3000);
Fix it with import.meta.dirname (Node 20.11+), or path.dirname(fileURLToPath(import.meta.url)) on older versions:
import express from "express";
import path from "node:path";
const app = express();
app.use(express.static(path.join(import.meta.dirname, "public")));
app.listen(3000);
2. Serving the entire project instead of a dedicated folder
Pointing express.static() at the project root (or ".") exposes everything in that directory to any visitor — including .env, node_modules, and your server’s own source code.
app.use(express.static("."));
Always point it at a directory that contains only files you intend to publish:
app.use(express.static("public"));
3. Using unescaped output in templates with user data
EJS has two output tags. <%- %> injects raw, unescaped HTML; <%= %> HTML-escapes the value. Using the raw tag with anything a user controls is a stored XSS vulnerability:
<p>Welcome, <%- user.name %></p>
If user.name contains a string like a <script> tag, it renders and executes in the visitor’s browser. Escape by default:
<p>Welcome, <%= user.name %></p>
Reserve <%- %> for HTML you generated and trust yourself, such as another rendered partial.
Best Practices
- Keep static assets in a dedicated folder (commonly named
public) and never pointexpress.static()at the project root. - Build the static root with
path.join(import.meta.dirname, "public")rather than a relative string, so it works regardless of the process’s current working directory. - Set a sensible
maxAgefor assets that rarely change (or use content-hashed filenames from a bundler) to let browsers cache aggressively. - Register
express.static()before your other routes when static files should take priority, and keep API routes under a distinct prefix like/apito avoid ambiguity. - Default to
<%= %>(or your engine’s escaping equivalent) for any value that originated from a user or an external source. - In production, set
NODE_ENV=productionso Express enables view caching and avoids recompiling templates on every request. - Serve genuinely large or user-uploaded files with streaming (or a CDN/object storage) rather than routing all traffic through your Express process.
- Never place
.env, credentials, or server source files inside the folder passed toexpress.static().
Practice Exercises
- Create a
publicfolder with anindex.htmland astyle.cssthat changes the background color. Serve it withexpress.static()and confirm both the page and the stylesheet load in a browser. - Mount a second static folder called
downloadsunder the URL prefix/files, withmaxAgeset to one week. Verify a file placed inside is reachable at/files/<filename>and that the response includes aCache-Controlheader. - Install EJS, create a
views/profile.ejstemplate that displays a user’snameandbiopassed from a route, and deliberately use<%- %>for the bio first. Then explain in a comment why you should change it to<%= %>, and make that change.
Summary
express.static(root, options)is middleware that serves files directly from a directory, setting MIME types and cache headers automatically, and falls through to the next middleware if no file matches.- An optional mount path lets the URL prefix differ from the folder name on disk; you can register multiple static directories.
- Template engines like EJS are configured with
app.set("view engine", ...)andapp.set("views", ...), and invoked withres.render(view, locals)to turn a template plus data into an HTML response. - Static and dynamic content coexist in the same app: rendered pages routinely reference CSS/JS served by
express.static(). - Use
import.meta.dirname, not__dirname, to build absolute paths in ES modules. - Never expose the whole project as static content, and always escape user-controlled data in templates to prevent XSS.
