SPAs and Server-Side Rendering

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against the official documentation and the package repositories on 2026-10-29. A front end built with React, Vue or Angular is, to the server, a folder of static files plus one HTML document that must be returned for every client-side route. Express can do that job in a few lines, and it can also render the application on the server for search engines and faster first paint. The page covers both: serving a built single-page application (SPA) with a history fallback, the choice between a same-origin and a separate-origin set-up, server-side rendering (SSR) with Vite running inside Express, production SSR, and the dynamic sitemap.xml and robots.txt routes that SSR sites need. The examples use ESM and the node: prefix for built-in modules, extend the Bookshelf API that grows across this section, and contain placeholders only, never real secrets. Static files are covered in depth in Serving Static Files, and CORS in CORS.

Serving a single-page application

A SPA is compiled by its build tool (Vite, the Angular CLI, and so on) into a directory, usually dist, that holds index.html and fingerprinted assets such as assets/index-4f2a9c.js. The browser loads index.html, and the client router then handles URLs such as /books/42 without asking the server. The server has two obligations:

  1. Serve the real files under dist (scripts, styles, images) with long-lived cache headers.

  2. Return index.html for every other navigation request, so that opening or reloading /books/42 does not produce a 404. This is the history fallback (the router uses the History API instead of # fragments).

flowchart TD A["Browser requests a URL"] --> B{"Path starts with /api?"} B -->|"yes"| C["API routers: JSON response or JSON 404"] B -->|"no"| D{"File exists in dist?"} D -->|"yes"| E["express.static sends the file"] D -->|"no"| F{"GET or HEAD that accepts HTML?"} F -->|"yes"| G["History fallback sends index.html"] F -->|"no"| H["404 or next handler"]

The history fallback with /{*splat}

In Express 4 the fallback was written app.get('*', ...). Express 5 uses path-to-regexp 8 (through router 2.2), and a bare * is rejected at startup with a "Missing parameter name" error: wildcards must be named. Two spellings matter:

Path Matches

/*splat

Any path with at least one segment after the slash (/books/42), but not /.

/{*splat}

Any path, including /. The braces make the wildcard optional. This is the right spelling for a SPA fallback.

/*splat read back from req.params

An array of segments: req.params.splat is ['books', '42'] for /books/42.

A complete Bookshelf server that serves an SPA build next to its API:

// server.js
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import express from 'express'

const dist = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dist')
const app = express()

app.disable('x-powered-by')
app.use(express.json())

// 1. The API comes first, so the fallback can never shadow it.
const api = express.Router()
api.get('/books', (req, res) => {
  res.json([{ id: 1, title: 'Bookshelf sample' }])
})
api.use((req, res) => {
  res.status(404).json({ error: 'Not found' })
})
app.use('/api', api)

// 2. Real files: fingerprinted assets are immutable, index.html is revalidated.
app.use(
  express.static(dist, {
    index: false,
    setHeaders(res, filePath) {
      if (filePath.includes(`${path.sep}assets${path.sep}`)) {
        res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
      }
    }
  })
)

// 3. History fallback: only for navigations, never for missing assets.
app.get('/{*splat}', (req, res, next) => {
  if (path.extname(req.path) || !req.accepts('html')) return next()
  res.setHeader('Cache-Control', 'no-cache')
  res.sendFile(path.join(dist, 'index.html'))
})

app.listen(3000, () => console.log('Listening on http://localhost:3000'))

The order is the whole design. The API router is mounted first and ends with its own JSON 404, so a mistyped /api/boks is not answered with HTML. express.static runs with index: false so that / falls through to the fallback, which controls the caching of index.html. The fallback itself refuses requests whose last segment has a file extension, and requests that do not accept HTML: a missing /assets/old.js stays a 404 instead of returning an HTML document that the browser then fails to parse as JavaScript. Because only app.get is used, POST and other methods never receive index.html. res.sendFile needs an absolute path (or the root option) and is the right call here because it handles ETag, ranges and errors; its errors reach the error handlers described in Error Handling.

The same fallback without a route: path-less middleware

A path-less app.use handler works in both Express versions and avoids the wildcard syntax altogether:

app.use((req, res, next) => {
  if (req.method !== 'GET' && req.method !== 'HEAD') return next()
  if (path.extname(req.path) || !req.accepts('html')) return next()
  res.sendFile(path.join(dist, 'index.html'))
})

connect-history-api-fallback

The connect-history-api-fallback package is the long-standing middleware for the same job, used by webpack and Vite development servers. It rewrites req.url to /index.html when the request is a GET or HEAD, accepts text/html (and not application/json), and whose path has no dot in its last segment; express.static placed after it then serves the rewritten URL.

import express from 'express'
import history from 'connect-history-api-fallback'

const app = express()

app.use('/api', apiRouter) // must be registered before the fallback

app.use(
  history({
    index: '/index.html',
    // Paths that must never be rewritten, even for HTML requests:
    rewrites: [{ from: /^\/api\/.*$/, to: (context) => context.parsedUrl.pathname }],
    disableDotRule: false
  })
)
app.use(express.static('dist'))

Its options are index (the document to serve), rewrites (regular expressions mapped to a path or a function), htmlAcceptHeaders (the accepted Accept values, default text/html and */*), disableDotRule (also rewrite paths that contain a dot, which is needed for client routes such as /files/report.v2) and verbose/logger for debugging. Choose the package when the fallback rules are complex or shared with a dev-server config. For the common case the three lines of /{*splat} are easier to read and add no dependency.

Same origin in production, separate origin in development

During development the front end usually runs on its own dev server (for example http://localhost:5173) and the API on http://localhost:3000. That is two origins, so the browser enforces CORS. There are two ways to deal with it:

Approach How Trade-off

Dev-server proxy

Vite (or the Angular CLI) forwards /api to Express, so the browser sees a single origin and no CORS or cookie configuration is needed.

Development only. Production needs the same shape, which Express provides by serving dist itself.

CORS

Express answers with Access-Control-Allow-Origin for the exact front-end origin, and cookies need credentials: true on both sides plus SameSite=None; Secure if the sites differ.

Needed when the front end is deployed on a different domain (a CDN or static host). Keep the allowed origin list explicit.

Serving the build from Express (same origin) is the simplest production topology: no CORS, first-party cookies with SameSite=Lax, one deployment. Moving the static files to a CDN is a valid optimisation later, at the price of the CORS and cookie configuration above. Behind a reverse proxy, remember app.set('trust proxy', 1) so that secure cookies and req.protocol work; see Application and Settings.

The front ends themselves are documented in the web section: React, Vue, Angular and Next.js.

Server-side rendering

A SPA sends an almost empty HTML document and builds the page in the browser. SSR renders the same components to HTML on the server, so that the first response already holds the content (better for search engines, link previews and the first paint), and the client then hydrates that markup, attaching event handlers without rebuilding it. The same application code therefore runs twice, and the project has two builds: a client bundle for the browser and a server bundle for Node.js.

The conventional layout of a Vite SSR project is:

index.html            template with an <!--ssr-outlet--> placeholder
src/entry-client.jsx  hydrates the markup in the browser
src/entry-server.jsx  exports render(url) used by the server
server.js             the Express server

Vite can run inside an Express application as middleware, so that development has hot module replacement and SSR from one process, while production uses the prebuilt bundles.

flowchart LR subgraph Development D1["Request"] --> D2["vite.middlewares: modules, HMR"] D2 --> D3["Read index.html and transformIndexHtml"] D3 --> D4["ssrLoadModule entry-server"] D4 --> D5["render url, inject HTML"] end subgraph Production P1["Request"] --> P2["compression"] P2 --> P3["express.static dist/client"] P3 --> P4["Import dist/server/entry-server.js once"] P4 --> P5["render url, inject HTML into cached template"] end

Vite in middleware mode

Creating the Vite server with server.middlewareMode: true stops it from starting its own HTTP server, and appType: 'custom' disables Vite’s own HTML handling and history fallback, leaving routing to Express. The Express application mounts vite.middlewares (a Connect-compatible stack) and handles HTML itself:

// server.js
import fs from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import express from 'express'
import compression from 'compression'

const root = path.dirname(fileURLToPath(import.meta.url))
const isProduction = process.env.NODE_ENV === 'production'
const port = Number(process.env.PORT ?? 3000)
const base = process.env.BASE ?? '/'

const app = express()
app.disable('x-powered-by')

let vite
let template = ''
let productionRender

if (isProduction) {
  template = await fs.readFile(path.join(root, 'dist/client/index.html'), 'utf-8')
  productionRender = (await import('./dist/server/entry-server.js')).render
  app.use(compression())
  app.use(base, express.static(path.join(root, 'dist/client'), { index: false }))
} else {
  const { createServer } = await import('vite')
  vite = await createServer({
    server: { middlewareMode: true },
    appType: 'custom',
    base
  })
  app.use(vite.middlewares)
}

app.get('/{*splat}', async (req, res, next) => {
  try {
    const url = req.originalUrl.replace(base, '/')

    let html
    let render
    if (isProduction) {
      html = template
      render = productionRender
    } else {
      html = await fs.readFile(path.join(root, 'index.html'), 'utf-8')
      html = await vite.transformIndexHtml(url, html)
      render = (await vite.ssrLoadModule('/src/entry-server.jsx')).render
    }

    const rendered = await render(url)
    html = html
      .replace('<!--ssr-head-->', rendered.head ?? '')
      .replace('<!--ssr-outlet-->', rendered.html ?? '')

    res.status(200).set({ 'Content-Type': 'text/html' }).send(html)
  } catch (error) {
    vite?.ssrFixStacktrace(error)
    next(error)
  }
})

app.listen(port, () => console.log(`SSR server on http://localhost:${port}`))

Each step has a reason:

  • vite.middlewares must be mounted before the HTML route. It serves transformed modules, assets and the HMR connection.

  • transformIndexHtml(url, html) injects the Vite client and any plugin-generated tags (React Fast Refresh preamble, for example) into the template. It is needed only in development.

  • ssrLoadModule loads the server entry through Vite’s transform pipeline, so changes are picked up on the next request without restarting Express. In production the bundle is imported once at start-up.

  • vite.ssrFixStacktrace(error) maps the stack trace back to the source files before the error is handed to Express. Production errors simply go to next(error) and the central error handler.

  • The template is read once in production and on every request in development, so that edits to index.html apply.

  • The route uses /{*splat} so that / is included. In Express 4 the same route is app.get('*', ...), which throws in Express 5.

The render function is a plain function of the URL, and it is the only place where a framework differs. A React entry would call renderToString (or a streaming renderer) with a router configured for the given location, a Vue entry would use renderToString from vue/server-renderer, and both hand back the markup and head tags. Production builds are created with vite build --outDir dist/client for the browser and vite build --ssr src/entry-server.jsx --outDir dist/server for the server. The Vite documentation also describes streaming and pre-rendering variants.

Pitfalls of hand-written SSR

  • Code that runs on the server must not touch window or document outside effects and lifecycle hooks.

  • Data needed for the first render has to be fetched before rendering and passed to the client (for example as a serialised JSON blob), otherwise hydration shows a mismatch warning. Serialise it safely: escape < so that a value containing </script> cannot close the tag.

  • State kept in module scope is shared between requests. Create the store, the query client and the router per request.

  • Send a real status code: a server-side "not found" render should respond with 404, not 200.

  • Production needs compression (or a reverse proxy doing it), long-lived caching for fingerprinted assets and a restart-free deployment of both bundles together.

Hand-written SSR or a meta-framework

Writing the SSR server yourself keeps one Express application in charge of the API, authentication and rendering, and suits teams that want the exact set of middleware from this section. It also means owning routing, data loading, code splitting, streaming, caching and the build pipeline. A meta-framework provides these: Next.js for React and Nuxt for Vue include file-system routing, data fetching, static generation and incremental regeneration, and they can sit in front of an Express API or be run behind a custom server. As a rule: choose a plain SPA served by Express when SEO and first paint do not matter (dashboards, internal tools); hand-written Vite SSR when a small number of public pages need rendering and the API already lives in Express; a meta-framework when most of the site is public content with many routes.

Dynamic sitemap.xml and robots.txt routes

Public SSR pages should be discoverable. robots.txt and sitemap.xml are ordinary Express routes and must be registered before the HTML fallback, otherwise /{*splat} answers them with the application shell. The sitemap is built from the same data as the pages:

const siteUrl = process.env.SITE_URL ?? 'https://bookshelf.example.com'

const escapeXml = (value) =>
  String(value).replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[c])

app.get('/robots.txt', (req, res) => {
  res.type('text/plain').send(['User-agent: *', 'Disallow: /api/', `Sitemap: ${siteUrl}/sitemap.xml`, ''].join('\n'))
})

app.get('/sitemap.xml', async (req, res) => {
  const books = await booksRepository.findAllPublished() // placeholder data access
  const urls = [
    { loc: `${siteUrl}/`, lastmod: new Date().toISOString() },
    ...books.map((book) => ({
      loc: `${siteUrl}/books/${encodeURIComponent(book.id)}`,
      lastmod: book.updatedAt.toISOString()
    }))
  ]

  const xml =
    '<?xml version="1.0" encoding="UTF-8"?>\n' +
    '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
    urls
      .map((u) => `  <url><loc>${escapeXml(u.loc)}</loc><lastmod>${u.lastmod}</lastmod></url>`)
      .join('\n') +
    '\n</urlset>'

  res.set('Cache-Control', 'public, max-age=3600').type('application/xml').send(xml)
})

Details to respect: a sitemap file is limited to 50,000 URLs and 50 MB uncompressed, after which a sitemap index of several files is required; every loc must be an absolute URL on the same host; and values must be XML-escaped, as the helper does. Cache the response (in memory or with a short Cache-Control) so that crawlers do not trigger a database scan on each fetch. res.type() sets the Content-Type from a short name or extension, as described in Response.

What changed recently

  • Express 5 rejects the bare wildcard. app.get('*', ...) and app.use('*', ...) throw at start-up; use /{*splat} or a path-less middleware. See Route Paths and Parameters.

  • express.static in Express 5 defaults dotfiles to 'ignore', so files under dot directories such as .well-known are not served unless that option is changed.

  • Async route handlers, such as the SSR route above, can throw or reject without a try/catch in Express 5, and the error reaches the error handlers. The try/catch in the example exists only to call vite.ssrFixStacktrace.

  • Vite’s middlewareMode and appType: 'custom' options are the documented way to embed it in Express. Check the Vite SSR guide for its newer environment-based APIs when upgrading.

Where the books are outdated

Older practice Where it appears Current behaviour

A app.get('*', ...) or app.use('*', ...) catch-all to serve the SPA or SSR page.

Sharma, chapter 6; Bugl, chapters 7 and 8

Throws at start-up in Express 5. Use /{*splat} or a path-less middleware, and register API, sitemap and robots routes first.

Returning index.html for every GET, including missing assets.

Sharma, chapter 6

Skip paths with a file extension and requests that do not accept HTML, so that a missing script is a real 404.

Running a separate dev server and relying on permissive CORS in production.

Sharma, chapter 5; Bugl, chapter 3

Prefer a dev-server proxy and serve the build from Express (one origin) in production; when origins differ, allow only the exact front-end origin.

body-parser and per-handler try/catch around the SSR render.

Bugl, chapters 3 and 7

Use express.json(); Express 5 forwards rejected promises to the error handlers.

Hand-built sitemap.xml strings without escaping or size limits.

Bugl, chapter 8

Escape values, respect the 50,000-URL limit, cache the result, and add robots.txt pointing at it.

The books target Express 4.0 to 4.18, so their SPA and SSR servers need the changes above to run on Express 5.