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 call res.render() without an explicit extension.
  • views — directory (or array of directories) Express searches for view files.
  • viewName — the template filename without extension, relative to the views directory.
  • 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

  1. A request arrives, e.g. GET /style.css. Express walks the middleware stack in registration order.
  2. It reaches express.static(). The middleware resolves style.css against its root directory, confirms the file exists and is not a directory, and reads it (streamed, not loaded into memory as one string).
  3. It sets Content-Type, Content-Length, ETag/Last-Modified, and any configured Cache-Control, then pipes the file into the response and calls res.end() implicitly. No later middleware or route ever sees this request.
  4. Now consider GET /products. express.static() finds no matching file, so it calls next() and Express keeps walking the stack.
  5. Express reaches the app.get("/products", ...) route, which matches, and the handler runs synchronously up to res.render("products", { products }).
  6. res.render() looks up products.ejs in the configured views directory, compiles the template into a JavaScript function (cached after the first compile when view caching is on), and invokes it with products exposed as a template-local variable.
  7. The compiled function returns an HTML string, which Express sends with res.send() semantics: correct Content-Type: text/html header and Content-Length set 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 point express.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 maxAge for 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 /api to 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=production so 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 to express.static().

Practice Exercises

  • Create a public folder with an index.html and a style.css that changes the background color. Serve it with express.static() and confirm both the page and the stylesheet load in a browser.
  • Mount a second static folder called downloads under the URL prefix /files, with maxAge set to one week. Verify a file placed inside is reachable at /files/<filename> and that the response includes a Cache-Control header.
  • Install EJS, create a views/profile.ejs template that displays a user’s name and bio passed 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", ...) and app.set("views", ...), and invoked with res.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.