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:
-
Serve the real files under
dist(scripts, styles, images) with long-lived cache headers. -
Return
index.htmlfor every other navigation request, so that opening or reloading/books/42does not produce a 404. This is the history fallback (the router uses the History API instead of#fragments).
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 |
|---|---|
|
Any path with at least one segment after the slash ( |
|
Any path, including |
|
An array of segments: |
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'))
})
Source: Express: Writing middleware
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 |
Development only. Production needs the same shape, which Express provides by serving |
CORS |
Express answers with |
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.
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
Source: Vite: Server-Side Rendering
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.
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.middlewaresmust 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. -
ssrLoadModuleloads 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 tonext(error)and the central error handler. -
The template is read once in production and on every request in development, so that edits to
index.htmlapply. -
The route uses
/{*splat}so that/is included. In Express 4 the same route isapp.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
windowordocumentoutside 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) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[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('*', ...)andapp.use('*', ...)throw at start-up; use/{*splat}or a path-less middleware. See Route Paths and Parameters. -
express.staticin Express 5 defaultsdotfilesto'ignore', so files under dot directories such as.well-knownare not served unless that option is changed. -
Async route handlers, such as the SSR route above, can throw or reject without a
try/catchin Express 5, and the error reaches the error handlers. Thetry/catchin the example exists only to callvite.ssrFixStacktrace. -
Vite’s
middlewareModeandappType: '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 |
Sharma, chapter 6; Bugl, chapters 7 and 8 |
Throws at start-up in Express 5. Use |
Returning |
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. |
|
Bugl, chapters 3 and 7 |
Use |
Hand-built |
Bugl, chapter 8 |
Escape values, respect the 50,000-URL limit, cache the result, and add |
The books target Express 4.0 to 4.18, so their SPA and SSR servers need the changes above to run on Express 5.