Not every site needs React. When your pages are mostly HTML with a bit of dynamic data — a dashboard, a blog, an admin panel — a server-side template engine is simpler, faster to ship, and has no build step. You write HTML with holes in it, the server fills the holes with data, and the browser gets finished pages. This guide uses EJS on Express: a shared layout, reusable partials, and a loop over real data.
| At a glance | |
|---|---|
| You need | A server running the Node.js application |
| Background | Build an Express website + API |
| Plan | Any plan |
| Time | Twenty-five minutes |
Templates vs a frontend framework
The line is about where the HTML is built:
| Server-rendered (EJS) | SPA (React/Vue) | |
|---|---|---|
| HTML is built | On the server, per request | In the browser, after loading JS |
| Build step | None | Yes (React SPA hosting) |
| First paint | Immediate, full HTML | After the JS bundle runs |
| Best for | Content, dashboards, forms | Rich, app-like interaction |
If your pages are content with some dynamic bits, server rendering is the shorter road. Reach for a SPA when the page behaves like an application, not a document.
Set it up
Install ejs and express-ejs-layouts from the Packages page (search each, install, restart). EJS renders templates; express-ejs-layouts adds a shared layout so you don't repeat your <head> and nav on every page. The folder layout:
index.js
views/
layout.ejs <- the shell every page shares
home.ejs <- page content
about.ejs
partials/
nav.ejs <- a reusable chunk
index.js — point Express at the views/ folder and tell it to use the layout:
const express = require('express');
const path = require('node:path');
const expressLayouts = require('express-ejs-layouts');
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(expressLayouts);
app.set('layout', 'layout'); // views/layout.ejs wraps every page
const posts = [
{ title: 'Hello world', body: 'My first server-rendered post.' },
{ title: 'Why EJS', body: 'Templates without a frontend framework.' },
];
app.get('/', (req, res) => res.render('home', { title: 'Home', posts }));
app.get('/about', (req, res) => res.render('about', { title: 'About' }));
const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));
res.render('home', { ... }) renders views/home.ejs, handing it the data object. Every value in that object becomes a variable inside the template.
The layout and a partial
views/layout.ejs — the shell. <%- body %> is where each page's content drops in; the nav is pulled in from a partial:
<!doctype html>
<html>
<head><title><%= title %> — My Site</title></head>
<body>
<%- include('partials/nav') %>
<main>
<%- body %>
</main>
<footer>Built with EJS on Falix</footer>
</body>
</html>
views/partials/nav.ejs — written once, reused everywhere:
<nav>
<a href="/">Home</a> ·
<a href="/about">About</a>
</nav>
views/home.ejs — the page content, with a loop over the data your route passed:
<h1><%= title %></h1>
<ul>
<% posts.forEach(function (post) { %>
<li><strong><%= post.title %></strong>: <%= post.body %></li>
<% }); %>
</ul>
Two EJS tags do almost everything here:
<%= value %>prints a value, escaped — safe for user data, because it turns<script>into harmless text.<%- value %>prints raw HTML — use it only for HTML you control, like the layout'sbodyor an included partial. Never wrap user input in<%-; that's how a cross-site-scripting hole opens.<% code %>runs JavaScript without printing — theforEachloop,ifconditions, and so on.
Verify it works
Start the server and open your address (Network page). The home page arrives as complete HTML: the layout's header and footer, the nav from the partial, and one <li> per post from the loop. curl http://YOUR_ADDRESS:PORT/ shows the finished markup — no JavaScript required to see content, because the server already rendered it. /about shares the same shell with different content.
🎯 Good to know: Because there's no build step, editing a
.ejstemplate takes effect on the next request — no restart needed for template changes. You only restart when you changeindex.jsitself.
Forms and real data
The natural next move is handling form submissions and pulling data from a database instead of a hard-coded array. Parse form posts with app.use(express.urlencoded({ extended: true })), read the fields off req.body, then re-render a page with the result. Swap the posts array for a query against a database and the same templates render live data. The Flask + SQLite guestbook shows the same server-rendered form pattern in another language.
Prefer a different engine? Handlebars (via express-handlebars) and Pug follow the identical Express pattern — set the view engine, render with data. EJS is the closest to plain HTML, which is why it's the gentlest start. The official EJS docs at ejs.co cover every tag and option.
Troubleshooting
Error: Cannot find module 'ejs'(orexpress-ejs-layouts) — it isn't installed. Add it on the Packages page and restart. The console also offers a one-click install when it spots this.Failed to lookup view "home"— the file name orviewspath is wrong. Confirmapp.set('views', ...)points at the folder and the file isviews/home.ejs.- Your HTML shows up as literal text on the page — you printed it with
<%=(escaped) where you meant<%-(raw). Only do that for HTML you control. - A page has no styling or nav — the layout isn't applied. Check
app.use(expressLayouts)andapp.set('layout', 'layout'), and thatlayout.ejsincludes<%- body %>.
Next steps
- Build an Express website + API — mix rendered pages with JSON routes
- Add a database — render live data
- React SPA hosting — when you outgrow server rendering
- Domains and HTTPS — give it a real address