Render HTML file in Node.js and Express.js framework

Visitors to your Express app need an HTML page back, either a saved file or one built for that request. Express sends a finished file with res.sendFile() and builds a page from a template with res.render().

Here I’ll send a saved page and its stylesheet, then render a page per request with Pug.

What does Express send when a route handles HTML?

Express can return a saved HTML document with res.sendFile(), or generate a response from a template with res.render(). The first sends a file from disk. The second asks a view engine to combine a template with data.

ApproachUse it whenExpress sends
res.sendFile()The HTML file is already completeThe file contents
res.render()The page needs values that change per requestHTML generated from a view and its data

Sending the document does not serve its linked files automatically. The browser requests a stylesheet or script by its own URL, so Express must map each asset URL to a file or route.

Prerequisites

Use a Node.js project with npm. The example uses CommonJS syntax and keeps browser files in public and Pug templates in views.

  • Node.js and npm available in your shell.
  • A project directory where you can create app.js, public, and views.

Initialize the project and install the two packages used by the complete example. The static route needs Express. Pug is used only for the dynamic view.

npm init -y
npm install express pug
mkdir -p public views

Step 1: Send the saved HTML page from a fixed root

Give res.sendFile() a fixed filename and an absolute root directory. Express accepts that relative filename only when root supplies the directory that contains it.

The / route sends the saved file. The /hello route passes a name to Pug, and the static middleware serves the stylesheet by URL.

const express = require('express');
const path = require('node:path');

const app = express();
const publicDir = path.join(__dirname, 'public');

app.set('view engine', 'pug');
app.set('views', path.join(__dirname, 'views'));

app.get('/', (_req, res) => {
  res.sendFile('index.html', { root: publicDir });
});

app.get('/hello', (req, res) => {
  const name = String(req.query.name || 'Ada');
  res.render('hello', { name });
});

app.use(express.static(publicDir));

const port = Number(process.env.PORT || 3100);
app.listen(port, () => {
  console.log(`Listening at http://localhost:${port}`);
});

The root option keeps the resolved file inside public, and the route always selects index.html rather than taking a filename from the request. The Express response API documents this path check. The res.sendFile() reference covers the method’s other file-transfer options.

Curl response showing Express sends the saved HTML file with HTTP 200
The route returns the saved HTML document as a text/html response.

The HTML links to /site.css, so express.static(publicDir) serves the stylesheet after the document response ends.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Express HTML response</title>
  <link rel="stylesheet" href="/site.css">
</head>
<body>
  <main>
    <p class="eyebrow">Express response</p>
    <h1>HTML file from Express</h1>
    <p>This page came from a saved HTML file. Its stylesheet is a separate request.</p>
    <a href="/hello?name=Ada">Open the dynamic greeting</a>
  </main>
</body>
</html>

The browser reads the HTML response, then requests the stylesheet URL separately. Keep each referenced file under the public directory or add a route that serves it.

* { box-sizing: border-box; }
body { margin: 0; background: #f4f7fb; color: #182338; font: 18px/1.55 system-ui, sans-serif; }
main { max-width: 760px; margin: 12vh auto; padding: 40px; background: white; border: 1px solid #d6deea; }
.eyebrow { color: #3568a8; font-size: 14px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
h1 { margin: 8px 0 12px; font-size: 40px; line-height: 1.15; }
a { color: #165db4; }

Step 2: Render changing values with Pug

Use res.render() when the response depends on data. Express looks for the view in the configured views directory, passes the locals to Pug, and sends the generated HTML.

The Express template-engine guide shows how the view engine and directory settings work. This view uses escaped interpolation for the supplied name.

doctype html
html(lang="en")
  head
    meta(charset="utf-8")
    meta(name="viewport", content="width=device-width, initial-scale=1")
    title Dynamic greeting
    link(rel="stylesheet", href="/site.css")
  body
    main
      p.eyebrow Pug view
      h1 Hello #{name}
      p Express rendered this template with a value from the request.
      a(href="/") Return to the saved HTML file

Pug escapes characters that could form an HTML tag when the template uses this interpolation. The executed request with an img-shaped name returned text rather than an image element, so keep untrusted values in escaped output.

Verify the HTML page and its assets

Start the server with PORT set to 3101, then request the page, stylesheet, and Pug route. Each response should match the resource the browser asked for.

export PORT=3101
node app.js

Run these requests separately. Their output shows the requested resource along with its HTTP status and content type.

curl -sS -w '\nHTTP %{http_code}, content type %{content_type}\n' http://localhost:3101/
curl -sS -w '\nHTTP %{http_code}, content type %{content_type}\n' http://localhost:3101/site.css
curl -sS -w '\nHTTP %{http_code}, content type %{content_type}\n' 'http://localhost:3101/hello?name=Ada'
RequestExpected responseObserved result
/HTML documentHTTP 200, HTML with UTF-8
/site.cssStylesheetHTTP 200, CSS
/hello?name=AdaGenerated greetingHTTP 200, HTML containing Hello Ada
Curl response showing Express renders a Pug view with Hello Ada
res.render returns HTML from the Pug view and its supplied value.

Troubleshoot missing files, styles, and views

A page can return HTML while a file path, asset URL, or template lookup fails. Match the response or error to the file and route that should handle it.

  • If Express says “path must be absolute or specify root to res.sendFile,” pass an absolute path or a relative filename with an absolute root. Keep request data out of the filename unless it is checked against a fixed allowlist.
  • If the page appears without its styling, request /site.css directly. A 404 means its URL does not map to the file, so check the public directory and the express.static path.
  • If Express cannot find the view, confirm the file is views/hello.pug and that app.set(‘views’, …) points to that directory. Install Pug in the project that runs the server.

A missing route returns 404 rather than the HTML file. Check the exact URL before changing the file path.

curl -sS -w '\nHTTP %{http_code}, content type %{content_type}\n' http://localhost:3101/does-not-exist
Curl response showing Express returns HTTP 404 for an unhandled route
A missing route returns 404 instead of the HTML page.

Keep the route’s file path fixed

Keep the filename in the route fixed and let the root option define where Express can read it. When the page needs values that change between requests, render a template instead of building HTML from a request string.

From the project directory, start the server with:

export PORT=3101
node app.js

Express HTML file questions

A successful HTML response proves only that its document route worked. A stylesheet or view lookup has its own path and its own response.

Can res.sendFile() use a relative filename?

Yes. Pass a relative filename with an absolute root option. Express checks that the resolved path stays inside that root.

Why does the HTML page load without its CSS?

The HTML response does not automatically serve linked assets. Map the asset URL to a file with express.static() or another route.

When should I use res.render() instead of res.sendFile()?

Use res.sendFile() for an existing document. Use res.render() when a view engine must combine a template with request or server data.

Pankaj Kumar
Pankaj Kumar

Pankaj Kumar is the founder and CEO of CodeForGeek, with more than 14 years in IT. He is an open-source enthusiast who enjoys sharing what he learns through CodeForGeek and YouTube, with a focus on Python, data analytics, machine learning, Angular, Node.js, and Kafka.

Articles: 336