Static Files & Templating
Express.js Fundamentals
Chapter 5 of 8 · Static Files & Templating
Static Files & Templating
Not every response is JSON. Express has two mechanisms for serving HTML to browsers: express.static() for pre-built files that never change per-request (CSS, images, client-side JS), and a template engine for pages that are generated on-the-fly from data. EJS is the simplest template engine that integrates with Express — it's just HTML with JavaScript expressions embedded. Understanding both, and knowing which to reach for, covers the full surface of Express as a web server rather than just an API.
express.static()
express.static(root, options) is Express's built-in middleware for serving files from a directory. When a request comes in, it looks for a file at root + req.path. If found, it streams the file with appropriate headers (Content-Type, ETag, Last-Modified). If not found, it calls next() — it does not send a 404 — so other routes can still handle the request.
Virtual Path Prefix
Pass a path prefix as the first argument to app.use() to serve files under a URL that doesn't match their directory structure. This is useful for namespacing assets or serving from node_modules.
express.static() Options
| Option | Default | Purpose |
|---|---|---|
| index | 'index.html' | File served when a directory is requested. Set to false to disable. |
| dotfiles | 'ignore' | How to handle dotfiles (.env, .htaccess). Keep as 'ignore' — never serve them. |
| etag | true | Enable ETag headers for caching. Set to false only if you manage caching yourself. |
| lastModified | true | Set Last-Modified to the file's mtime. |
| maxAge | 0 | Cache-Control max-age in milliseconds or a string ('1d', '30 days'). Set a long value for versioned assets. |
| redirect | true | Redirect trailing-slash-less directory URLs to add a slash. Usually fine to leave on. |
| setHeaders | — | Function (res, path, stat) to set custom headers on specific files. |
| fallthrough | true | When true, missing files call next(). When false, they generate a 404 error. |
💡 Static Files Belong Before Route Handlers, But After Security Middleware
Mount express.static() early so static file requests are short-circuited before reaching authentication middleware, body parsers, or rate limiters — there's no point running all that for a CSS file. But always mount Helmet and CORS first, so even static responses carry security headers. The typical order: helmet() → cors() → express.static() → body parsers → routes.
Server-Side Templating with EJS
A template engine lets you generate HTML on the server by merging a template with data. EJS (Embedded JavaScript) is the simplest option for Express: it's plain HTML with <% %> tags for JavaScript. No new syntax to learn — if you know JavaScript, you can write EJS immediately.
EJS Syntax Reference
| Tag | Example | Does |
|---|---|---|
| <%= ... %> | <%= user.name %> | Output value — HTML-escaped. Always use this for user data. |
| <%- ... %> | <%- htmlString %> | Output value — unescaped. Only for trusted HTML you generated yourself (e.g. partials, Markdown-to-HTML). |
| <% ... %> | <% if (user) { %> | Execute JavaScript — no output. Used for conditionals, loops, variable assignment. |
| <%# ... %> | <%# TODO: fix this %> | Comment — stripped from output, never sent to the browser. |
| <%- include(...) %> | <%- include('partials/nav', { active: 'home' }) %> | Include another EJS file. The included file shares the current scope plus any passed locals. |
| <%_ ... _%> | <%_ if (x) { _%> | Whitespace-slurping variants — remove newlines before/after the tag. Useful for clean HTML output. |
A Complete EJS Example
Partials — Shared Template Fragments
EJS doesn't have a formal layout system like Handlebars or Pug, but include() is enough to build one. The standard pattern is a header partial, a footer partial, and each page includes both.
res.locals — Globals for Every Template
Any property on res.locals is automatically available as a local variable in every res.render() call — no need to pass it explicitly. This is the right place for things every page needs: the logged-in user, flash messages, CSRF tokens.
SSR vs SPA — When to Use Templating
Use Server-Side Rendering (EJS)
Content-heavy pages: blogs, documentation, product listings — SEO and fast first paint matter.
Admin panels where you control all clients.
Form-heavy pages: login, checkout, settings — simpler without a SPA framework.
When you want to ship a complete web app without a separate frontend build step.
Use a SPA / API-only Express
Rich interactive UIs: dashboards with live data, drag-and-drop, complex state.
When your frontend team uses React/Vue/Angular and Express is just the API.
Mobile apps consuming a REST or GraphQL API.
When you need different clients (web + mobile) consuming the same backend.
⚠ Always Use <%= %> for User Data — Never <%- %>
<%= value %> HTML-escapes its output: <script> becomes <script> — harmless text. <%- value %> outputs raw HTML — if value contains user input, it is a stored XSS vulnerability. Only use <%- %> for HTML you generated yourself (partials, Markdown rendered server-side). Treat it like innerHTML: powerful, dangerous with untrusted input.
Coding Challenges
Challenge 1 — express.static() Configuration
Set up an Express app that serves a public/ directory with production-appropriate options: maxAge: '1d' for all files, but a setHeaders function that overrides this to Cache-Control: no-store for any file whose path ends in .html; dotfiles: 'ignore'; etag: true. Mount the static middleware at the virtual prefix /assets. Also mount a second static directory uploads/ (no virtual prefix) for user-uploaded files. Add a regular route GET / that returns { status: 'ok' } — confirm it's still reachable even with static middleware registered. Write supertest tests verifying: a request to /assets/test.txt that serves a real file returns 200; a request for a non-existent file under /assets calls through to the next handler (404 from your catch-all); and an attempt to fetch /assets/.env does not serve the file.
Challenge 2 — EJS Rendering
Build an app with EJS configured as the view engine. Create a views/ directory with: partials/header.ejs (DOCTYPE, head with title local, link to /css/styles.css), partials/footer.ejs (closes body/html), index.ejs (uses both partials, shows a welcome message and an item count), items/list.ejs (uses partials, loops over an items array with name and price, shows "No items" if empty). Add middleware that sets res.locals.appName = 'My Store'. Routes: GET / renders index with { title: 'Home', itemCount: 3 }; GET /items renders items/list with 3 dummy items; GET /items/empty renders items/list with an empty array. Write supertest tests checking: correct status codes; HTML response Content-Type; the rendered HTML contains expected strings (item names, "No items" for the empty case, the appName in the footer or a heading).
Challenge 3 — res.locals Globals & Error Page
Extend the app from Challenge 2. Add a middleware that writes res.locals.year (current year), res.locals.currentUser (set to null, or to { name: 'Alice' } when an x-user: alice request header is present). Create views/error.ejs that displays the error status code, message, and res.locals.year in the footer. Register an error-handling middleware (4-param) that calls res.render('error', { title: 'Error', status, message }) — but only if the client accepts HTML (use req.accepts('html')); otherwise send JSON. Add a route GET /crash that calls next(createError(500, 'Something broke')). Write supertest tests: /crash with Accept: text/html returns an HTML page containing the status code; /crash with Accept: application/json returns JSON; a route with the x-user: alice header causes res.locals.currentUser.name to equal 'Alice' (verify via a dedicated GET /whoami route that renders a small template showing the username).