Static Files & Templating

Express.js Fundamentals — 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.

// Basic setup — serve everything in the 'public' directory app.use(express.static('public')); // GET /logo.png → serves public/logo.png // GET /css/styles.css → serves public/css/styles.css // GET / → serves public/index.html (if it exists) // Best practice: use an absolute path so the app works regardless of // the directory it's started from const path = require('node:path'); app.use(express.static(path.join(__dirname, 'public')));

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.

// Files in 'public/' are served at /static/* app.use('/static', express.static(path.join(__dirname, 'public'))); // GET /static/css/styles.css → serves public/css/styles.css // GET /css/styles.css → 404 (no route matches) // Serving Bootstrap from node_modules app.use('/vendor/bootstrap', express.static(path.join(__dirname, 'node_modules/bootstrap/dist')) ); // <link href="/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet"> // Multiple static directories — checked in registration order app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'uploads'))); // Looks in public/ first; if not found, looks in uploads/

express.static() Options

OptionDefaultPurpose
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.
etagtrueEnable ETag headers for caching. Set to false only if you manage caching yourself.
lastModifiedtrueSet Last-Modified to the file's mtime.
maxAge0Cache-Control max-age in milliseconds or a string ('1d', '30 days'). Set a long value for versioned assets.
redirecttrueRedirect trailing-slash-less directory URLs to add a slash. Usually fine to leave on.
setHeadersFunction (res, path, stat) to set custom headers on specific files.
fallthroughtrueWhen true, missing files call next(). When false, they generate a 404 error.
// Production static configuration app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1y', // cache versioned assets for a year etag: true, lastModified: true, dotfiles: 'ignore', // never expose .env etc. index: 'index.html', // Add custom headers to all served files setHeaders(res, filePath) { if (filePath.endsWith('.html')) { // HTML files should not be cached aggressively res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate'); } }, }));

💡 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.

// npm install ejs // Tell Express which template engine to use app.set('view engine', 'ejs'); // Where to find templates (default: ./views/) app.set('views', path.join(__dirname, 'views')); // res.render(templateName, locals) // templateName is relative to the views dir, without the .ejs extension app.get('/products', async (req, res) => { const products = await db.query('SELECT * FROM products'); res.render('products/index', { // renders views/products/index.ejs title: 'All Products', products, user: req.user, }); });

EJS Syntax Reference

TagExampleDoes
<%= ... %> <%= 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

<!-- views/products/index.ejs --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title><%= title %></title> <link rel="stylesheet" href="/css/styles.css"> </head> <body> <!-- Include a shared navigation partial --> <%- include('../partials/nav', { active: 'products' }) %> <h1><%= title %></h1> <%# Conditional: show a message when the list is empty %> <% if (products.length === 0) { %> <p>No products found.</p> <% } else { %> <ul> <%# Loop over the products array %> <% products.forEach(function(product) { %> <li> <%# Always use <%= %> for user-supplied data — it HTML-escapes the output %> <strong><%= product.name %></strong> — $<%= product.price.toFixed(2) %> <a href="/products/<%= product.id %>">View</a> </li> <% }); %> </ul> <% } %> <%# Output the logged-in username if available %> <% if (user) { %> <p>Logged in as <strong><%= user.name %></strong></p> <% } %> </body> </html>

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.

views/ ├── partials/ │ ├── header.ejs # <!DOCTYPE html> … </head><body> │ ├── nav.ejs # <nav> … </nav> │ └── footer.ejs # </body></html> ├── products/ │ ├── index.ejs # list page │ └── show.ejs # single product page ├── index.ejs # home page └── error.ejs # error page (rendered by error handler)
<!-- views/partials/header.ejs --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title><%= title ?? 'My App' %></title> <link rel="stylesheet" href="/css/styles.css"> </head> <body> <!-- views/partials/footer.ejs --> <footer><p>&copy; 2025 My App</p></footer> </body> </html> <!-- views/index.ejs — a page using partials --> <%- include('partials/header', { title: 'Home' }) %> <main> <h1>Welcome to My App</h1> <p>There are <%= productCount %> products available.</p> </main> <%- include('partials/footer') %>

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.

// Middleware: attach user to res.locals so every template can access it app.use((req, res, next) => { res.locals.currentUser = req.user ?? null; res.locals.appName = 'My App'; res.locals.year = new Date().getFullYear(); next(); }); // Route handler — doesn't need to re-pass currentUser app.get('/dashboard', (req, res) => { res.render('dashboard', { title: 'Dashboard', stats: { visits: 142, sales: 9 }, // currentUser and appName are already in res.locals }); }); // In the template: <%= currentUser?.name %> just works

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 &lt;script&gt; — 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.

View sample solution ↗

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).

View sample solution ↗

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).

View sample solution ↗