Not every Express app is a JSON API. Sometimes we serve a full website — HTML pages, CSS files, images, client-side JS. Express has two built-in tools for this: express.static for files that don’t change, and view engines for HTML we generate from data.
In simple language: static = “send this file as-is”, templating = “fill in the blanks, then send.”
Static files — express.static
const path = require("path");
app.use(express.static(path.join(__dirname, "public")));
Now anything in public/ is reachable directly:
public/
styles.css → GET /styles.css
js/app.js → GET /js/app.js
images/logo.png → GET /images/logo.png
favicon.ico → GET /favicon.ico
Notice there’s no /public prefix in the URL — Express strips the folder name. If we want a prefix:
app.use("/assets", express.static("public"));
// Now: /assets/styles.css → public/styles.css
Useful options
app.use(express.static("public", {
maxAge: "1d", // Cache-Control: max-age=86400
etag: true, // send ETag for conditional GETs
index: "home.html", // default file for directories (instead of index.html)
dotfiles: "ignore", // skip files starting with . (like .env)
}));
In production we usually slap a CDN or nginx in front of static assets, but express.static is perfect for dev or small apps.
Templating — server-rendered HTML
A view engine takes a template file with placeholders and data, and spits out HTML. Express supports many — the popular ones are EJS, Pug, and Handlebars. Pick one based on syntax preference.
HTML + <%= %>
familiar, no learning curve
indentation-based
terse but unique syntax
HTML + {{ }}
logic-less, popular
Setting up EJS
npm install ejs
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views")); // default is ./views
app.get("/users/:id", async (req, res) => {
const user = await db.findUser(req.params.id);
res.render("user-profile", { user, title: "Profile" });
});
res.render(template, data) finds views/user-profile.ejs, runs it with the data, sends HTML.
views/user-profile.ejs
<!DOCTYPE html>
<html>
<head><title><%= title %></title></head>
<body>
<h1>Hello, <%= user.name %></h1>
<% if (user.isAdmin) { %>
<a href="/admin">Admin Panel</a>
<% } %>
<ul>
<% user.posts.forEach(post => { %>
<li><%= post.title %></li>
<% }); %>
</ul>
</body>
</html>
Three EJS tags to remember:
<%= value %>— print value, escaped (XSS-safe)<%- value %>— print value, raw HTML (use sparingly)<% code %>— execute JS, don’t print
Pug version
npm install pug
app.set("view engine", "pug");
doctype html
html
head
title= title
body
h1 Hello, #{user.name}
if user.isAdmin
a(href="/admin") Admin Panel
ul
each post in user.posts
li= post.title
No closing tags, indentation defines nesting. Some love it, some hate it.
Handlebars version
npm install express-handlebars
const { engine } = require("express-handlebars");
app.engine("handlebars", engine());
app.set("view engine", "handlebars");
<h1>Hello, {{user.name}}</h1>
{{#if user.isAdmin}}
<a href="/admin">Admin Panel</a>
{{/if}}
<ul>
{{#each user.posts}}
<li>{{this.title}}</li>
{{/each}}
</ul>
Logic-less by design — keeps templates clean and forces business logic into the route handler.
Putting it all together
A typical server-rendered Express app:
const express = require("express");
const path = require("path");
const app = express();
// Serve CSS/JS/images
app.use(express.static(path.join(__dirname, "public")));
// HTML pages
app.set("view engine", "ejs");
app.get("/", (req, res) => {
res.render("home", { title: "Welcome" });
});
app.get("/users/:id", async (req, res) => {
const user = await db.findUser(req.params.id);
res.render("user-profile", { user });
});
app.listen(3000);
For modern apps we’d usually go SPA (React/Vue) + JSON API. But for blogs, dashboards, or admin panels, server-rendered templates are still the fastest way to ship.