Not every Node.js server is a pure JSON API. Many also serve websites: the HTML, CSS, JavaScript and images that browsers load. Some send the same files to everyone, such as a single-page app built with React or a set of static pages. Others build HTML on the server for each request, filling in a user's name, their orders or search results.
Express handles both. express.static serves a whole folder of files with one line, setting content types, handling caching and returning 404 for missing files. For dynamic pages, a template engine such as EJS, Pug or Handlebars lets you write HTML with placeholders that are filled with data when the page is rendered. This is server-side rendering, and it's fast, simple and friendly to search engines.
In this lesson you'll serve static assets with sensible caching, send individual files and downloads, render pages from templates, keep user content safe by escaping it, and handle HTML form submissions properly. You'll also learn when server-rendered pages are the right choice and when a separate front end makes more sense.
Serving a folder of files
import express from 'express';
import path from 'node:path';
const app = express();
app.use(express.static(path.join(import.meta.dirname, 'public')));- A request for
/css/site.cssreturnspublic/css/site.css; a request for/returnspublic/index.html. - Mount it under a prefix to keep URLs organised:
app.use('/assets', express.static('public'))servespublic/logo.svgat/assets/logo.svg. - Several static folders can be added; Express checks them in order.
- Always build the folder path from
import.meta.dirnameso it works whatever directory the server is started from. - Static middleware only serves files inside the folder; requests like
/../server.jsare rejected.
Caching and individual files
Browsers can cache files so they aren't downloaded on every visit:
app.use('/assets', express.static('public/assets', { maxAge: '1y', immutable: true }));Long caching is safe for files whose names change when their content changes, such as app.3f9a1c.js produced by build tools. HTML should be cached briefly or not at all, so users always get the latest version.
To send one specific file, use res.sendFile(absolutePath); to make the browser download it with a file name, use res.download(path, 'report.csv').
Templates with EJS
npm install ejsapp.set('view engine', 'ejs');
app.set('views', path.join(import.meta.dirname, 'views'));
app.get('/products/:id', (req, res) => {
res.render('product', { product, title: product.name });
});<!-- views/product.ejs -->
<h1><%= product.name %></h1>
<p>Price: £<%= product.price.toFixed(2) %></p>
<% if (product.stock === 0) { %><p>Out of stock</p><% } %>
<%- include('partials/footer') %><%= value %>outputs a value escaped, so<script>typed by a user is shown as text, not run.<% code %>runs JavaScript, such asifand loops, without output.<%- value %>outputs raw, unescaped HTML. Use it only for trusted content such as other templates.includereuses partials like headers and footers across pages.
Node.js Web Syntax
Static files and templates
app.use(express.static(path.join(import.meta.dirname, 'public')));
app.use('/assets', express.static('dist/assets', { maxAge: '1y', immutable: true }));
res.sendFile(path.join(import.meta.dirname, 'public', 'terms.html'));
res.download('exports/orders.csv', 'orders-march.csv');
app.set('view engine', 'ejs');
res.render('home', { user, products });
app.use(express.urlencoded({ extended: false }));
res.redirect(303, '/thanks');Node.js Web Summary
- express.static serves a folder of files; build its path from import.meta.dirname.
- Mount static folders under prefixes and cache fingerprinted assets for a long time.
- res.sendFile sends one file; res.download triggers a download.
- Template engines such as EJS render HTML from data with res.render.
- <%= %> escapes output and prevents XSS; use <%- %> only for trusted HTML.
Node.js Web Examples
Each example shows the code, the output it produces and what each part does.
Serving a public folder
import express from 'express';
import path from 'node:path';
import { mkdir, writeFile } from 'node:fs/promises';
const pub = path.join(import.meta.dirname, 'public-demo');
await mkdir(path.join(pub, 'css'), { recursive: true });
await writeFile(path.join(pub, 'index.html'), '<h1>Shop</h1>');
await writeFile(path.join(pub, 'css', 'site.css'), 'body { margin: 0; }');
const app = express();
app.use(express.static(pub));
app.use('/assets', express.static(path.join(pub, 'css'), { maxAge: '1y', immutable: true }));
const server = app.listen(0, async () => {
const base = `http://localhost:${server.address().port}`;
for (const p of ['/', '/css/site.css', '/assets/site.css', '/missing.png']) {
const r = await fetch(base + p);
console.log(p, r.status, (r.headers.get('content-type') ?? '-').split(';')[0], r.headers.get('cache-control') ?? '');
}
server.close();
});/ 200 text/html public, max-age=0
/css/site.css 200 text/css public, max-age=0
/assets/site.css 200 text/css public, max-age=31536000, immutable
/missing.png 404 text/html Line-by-line explanation
- Lines 5 to 8The demo creates a small public folder; in a real project it already exists.
- Line 11One line serves every file in the folder, with the right content types.
- Line 12A second mount serves files under /assets with a one-year cache.
- Line 16A request for / returns index.html automatically.
Rendering a page with escaped data
function escapeHtml(text) {
return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
}
function renderProduct({ name, price, reviews }) {
const items = reviews.map((r) => `<li>${escapeHtml(r.author)}: ${escapeHtml(r.text)}</li>`).join('');
return `<h1>${escapeHtml(name)}</h1><p>£${price.toFixed(2)}</p><ul>${items}</ul>`;
}
const html = renderProduct({
name: 'Desk lamp',
price: 24,
reviews: [
{ author: 'Ada', text: 'Lovely & bright' },
{ author: 'Mallory', text: '<script>steal(document.cookie)</script>' },
],
});
console.log(html);<h1>Desk lamp</h1><p>£24.00</p><ul><li>Ada: Lovely & bright</li><li>Mallory: <script>steal(document.cookie)</script></li></ul>Line-by-line explanation
- Lines 1 to 3Escaping replaces the five characters that have special meaning in HTML.
- Line 6Every piece of user-written text is escaped before it goes into the page.
- Line 15The attacker's script tag is turned into harmless text that the browser displays instead of running. Template engines do exactly this with <%= %>.
A form with Post/Redirect/Get
import express from 'express';
const messages = [];
const app = express();
app.use(express.urlencoded({ extended: false }));
app.get('/contact', (req, res) => {
res.send(`<form method="post"><input name="email"><textarea name="message"></textarea><button>Send</button></form>`);
});
app.post('/contact', (req, res) => {
const { email = '', message = '' } = req.body;
if (!email.includes('@') || message.trim().length < 5) {
return res.status(400).send('Please enter an email and a message of at least 5 characters.');
}
messages.push({ email, message: message.trim() });
res.redirect(303, '/contact/thanks');
});
app.get('/contact/thanks', (req, res) => res.send('Thanks, we will reply soon.'));
const server = app.listen(0, async () => {
const url = `http://localhost:${server.address().port}/contact`;
const send = (fields) => fetch(url, { method: 'POST', body: new URLSearchParams(fields), redirect: 'manual' });
let r = await send({ email: 'ada@example.com', message: 'Is the lamp dimmable?' });
console.log(r.status, r.headers.get('location'));
r = await send({ email: 'nope', message: 'Hi' });
console.log(r.status, await r.text());
console.log(messages.length);
server.close();
});303 /contact/thanks
400 Please enter an email and a message of at least 5 characters.
1Line-by-line explanation
- Line 5express.urlencoded parses form submissions into req.body.
- Lines 13 to 15The form data is validated before it's stored.
- Line 17After a successful POST, a 303 redirect sends the browser to a GET page, so refreshing the thanks page doesn't resubmit the form. This is the Post/Redirect/Get pattern.
Common Node.js Web Mistakes
Relative static folder paths
app.use(express.static('public')); // breaks when the server is started from another folderapp.use(express.static(path.join(import.meta.dirname, 'public')));Unescaped user content
res.send(`<h1>Results for ${req.query.q}</h1>`); // ?q=<script>... runs in the pageres.render('search', { q: req.query.q }); // <%= q %> escapes itRendering a page directly after a form POST
app.post('/order', (req, res) => {
saveOrder(req.body);
res.render('thanks'); // refreshing the page submits the order again
});app.post('/order', (req, res) => {
saveOrder(req.body);
res.redirect(303, '/order/thanks');
});Node.js Web in Real Projects
Static files and server rendering in practice
Many Node apps serve a built React or Vue front end from express.static alongside a JSON API, with fingerprinted assets cached for a year. Admin panels, email templates, documentation sites and simple content sites are often server-rendered with EJS, Handlebars or Nunjucks, which keeps them fast and simple. Larger frameworks such as Next.js, Remix and Astro build on the same ideas with React and other component libraries.
Node.js Web FAQ
Why does express.static return 404 for my CSS file?
Usually the folder path or the URL is wrong. Build the folder with path.join(import.meta.dirname, 'public') so it doesn't depend on where you started node, and remember the folder name isn't part of the URL: public/css/site.css is served at /css/site.css, or at /assets/css/site.css if you mounted it under /assets.
What is the difference between res.sendFile and res.download?
res.sendFile sends a file for the browser to display or use, such as an image or a PDF shown inline. res.download sends it with a Content-Disposition: attachment header, so the browser saves it, and lets you choose the file name the user sees. Both need an absolute path, or a root option.
What is the difference between <%= %> and <%- %> in EJS?
<%= value %> escapes HTML characters, so <script> is shown as text; use it for anything that came from users or a database. <%- value %> outputs raw HTML exactly as given, which is meant for trusted content such as an include of another template. Using <%- with user data opens the door to cross-site scripting.
Which template engine should I use with Express?
EJS is a popular choice because it's plain HTML with JavaScript tags, so there's little new syntax to learn. Pug uses indentation instead of closing tags, and Handlebars keeps logic out of templates with a smaller set of helpers. All of them plug into Express with app.set('view engine', ...) and res.render.
Should Node serve static files in production?
For small and medium apps, express.static with sensible caching is perfectly fine. Busy sites often put a CDN or a web server such as Nginx in front, which serves files and handles caching and compression more efficiently, leaving Node free for dynamic requests. The code doesn't change much either way, since the CDN simply caches what Express serves.
Node.js Web Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the line to serve the public folder.
app.use(express.(path.join(import.meta.dirname, 'public')));
Which EJS tag outputs a value safely escaped?
What does res.download do?
app.use('/static', express.static('public')) is set up. Which URL returns public/img/logo.png?
Why redirect with 303 after a successful form POST?
Node.js Web Quiz
5 questions to check what you learned.
Which middleware parses HTML form submissions?
- express.json()
- express.static()
- express.urlencoded()
- express.form()
Which files are safe to cache for a year?
- index.html
- API responses
- Every file
- Fingerprinted assets like app.3f9a1c.js whose names change with their content
What attack does escaping user content prevent?
- Cross-site scripting (XSS)
- SQL injection
- Denial of service
- Brute force
How do you render the template views/home.ejs with data?
- res.send('home', data)
- res.render('home', data)
- res.template('home.ejs')
- res.sendFile('home.ejs')
Can express.static serve files outside its folder for a URL like /../server.js?
- Yes
- Only on Windows
- No, it rejects paths that leave the folder
- Only for .js files