Server-rendered sites with EJS

Build a multi-page site with real HTML templates — a shared layout, reusable partials, and data from your server — using EJS on Express, no frontend framework and no build step. Verified render included.

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's body or an included partial. Never wrap user input in <%-; that's how a cross-site-scripting hole opens.
  • <% code %> runs JavaScript without printing — the forEach loop, if conditions, 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 .ejs template takes effect on the next request — no restart needed for template changes. You only restart when you change index.js itself.

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' (or express-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 or views path is wrong. Confirm app.set('views', ...) points at the folder and the file is views/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) and app.set('layout', 'layout'), and that layout.ejs includes <%- body %>.

Next steps

Was this guide helpful?