skip to content

A single-page app routes with history.pushState(). Clicking through to /reports/42 works, but reloading that URL returns the server's 404 page. Why does the reload fail, and what has to change on the server?

level: juniorimportance: must knowfreq 66%

answer

  1. two routers, only one is remote
  2. the reload is a real request
  3. pushState never touched the server
  4. fallback rewrite to the app shell
  5. try_files … /index.html, status 200

basics

~20 s

history.pushState only rewrites the URL inside the browser; the server never learns the route exists. Reloading sends a real HTTP request for /reports/42, which 404s until the server is configured to return index.html for any unmatched path.

solid answer

~50 s

Client-side routing and server routing are two different things. `history.pushState()` changes the address bar and pushes a session-history entry without sending any request, so /reports/42 exists only in the running page's memory. A reload is a genuine `GET /reports/42`, and a static server that only has index.html and a bundle has nothing at that path, so it answers 404. The fix is a fallback rewrite: any request that does not match a real file or an API route serves `index.html` with a **200** status, and the JavaScript router then reads `location.pathname` and renders the right view. Keep `/api/*` and the asset directory out of the fallback so a genuinely missing script returns 404 instead of HTML. On nginx this is `try_files $uri $uri/ /index.html;`; static hosts expose the same thing as a rewrite rule or an SPA fallback setting.

code

javascript · 15 lines
javascript
const express = require('express');
const path = require('path');

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

app.use('/api', require('./api'));
app.use(express.static(dist));

// Anything not a real file and not an API route gets the app shell, 200.
app.use((req, res) => {
  res.status(200).sendFile(path.join(dist, 'index.html'));
});

app.listen(3000);

go deeper

for a junior

Be able to say plainly that the URL was changed only in the browser and that a reload asks the server for a path it does not have. Naming the index.html fallback is the expected answer.

for a middle

Explain the two-router split, write the nginx or Apache rule from memory, and point out that the fallback must exclude API and asset paths or missing files return HTML.

for a senior

Show the diagnosis path — check the document request's status in the Network panel — and raise soft 404s, base-href/relative-asset breakage, and where SSR or prerendering becomes necessary for correct status codes.

for a principal

Own the decision of where routing authority lives: CDN rewrite, edge function, origin server, or a rendering strategy that resolves routes server-side. Weigh operational simplicity against SEO, caching, and consistency across every environment the app ships to.

## Two different routers A single-page app has two routing layers that never talk to each other. The **server router** maps an incoming HTTP path to a file or a handler. The **client router** maps `location.pathname` to a component tree, entirely inside an already-loaded document. `history.pushState()` belongs to the second layer only: it appends an entry to the tab's session history and rewrites the visible URL. It sends nothing over the network — no request, no response, no server log line. That asymmetry is exactly what makes deep links break. When the user clicks a link and your router calls `pushState`, the server is never consulted, so /reports/42 "works". When the user presses reload, bookmarks the page, or pastes the URL into a fresh tab, the browser performs a real cross-document navigation: `GET /reports/42`. A static file server looks for `reports/42` on disk, finds nothing, and returns 404. Same URL, completely different code path. ## The fallback rewrite The standard fix is a catch-all rewrite: if the request does not match a real file, serve the app shell (`index.html`) so the bundle boots and the client router takes over. ```nginx location / { try_files $uri $uri/ /index.html; } ``` Apache with mod_rewrite expresses the same idea: ```apache RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.html [L] ``` Static hosting products all ship an equivalent knob — a rewrite rule to `/index.html` with status 200, or a checkbox literally labelled "single-page app". On an object store fronted by a simple web server, people sometimes set the bucket's *error document* to index.html; that appears to work but it answers with a 404 status while returning HTML, which confuses crawlers and monitoring. Prefer a real rewrite that returns 200. ## Three details that separate a working config from a sloppy one **Do not swallow everything.** The fallback must not cover `/api/*`, `/assets/*`, `/static/*`, or files like `robots.txt` and `favicon.ico`. If it does, a mistyped bundle URL returns your HTML page with `Content-Type: text/html`, and the browser reports the cryptic "Unexpected token '<'" syntax error instead of a clean 404. Order the rules so real files and API prefixes are matched first. **Status codes are part of the contract.** The rewrite means every path now returns 200, including /reprots/42 typed by a human. A pure client-side app cannot fix that: the shell is already sent before the router knows the route is bogus, so unknown routes are "soft 404s". If correct status codes matter for SEO or for uptime checks, you need server-side rendering or prerendering that can decide the status before responding — that is a rendering-strategy decision, not something the History API can do. **Relative URLs shift under deep paths.** With the shell served at /reports/42, a relative asset reference like `<script src="bundle.js">` resolves to /reports/bundle.js and 404s. Use root-relative paths (`/bundle.js`) or set `<base href="/">` in the shell. ## The alternative, and why it is rarely chosen now Hash-based routing sidesteps the whole problem. In `https://example.com/#/reports/42` the fragment is never sent to the server, so every URL is a request for `/` and no rewrite is needed. The costs are aesthetic and functional: uglier URLs, the fragment is unavailable for real anchors, and some analytics and server-side tooling ignores it. Teams still pick it when they cannot control server configuration at all — a documentation site dropped on a bare object store, or an app served from inside another product. ## How to diagnose this in ten seconds Open the failing URL in a new tab with DevTools' Network panel open and look at the *first* document request. If it is 404 with a server-branded body, the rewrite is missing. If it is 200 and returns your index.html but the screen is blank, the server is fine and the bug is in the client router's parsing of `location.pathname` — or in asset paths that broke because the shell is now served from a nested path.

  • After adding the fallback, a mistyped script URL returns HTML and the console shows "Unexpected token '<'". What happened?
    The catch-all is too greedy: it matched the asset request and served index.html with a 200 and `Content-Type: text/html`. The browser tried to parse HTML as JavaScript. Exclude the asset and API prefixes from the fallback so missing files return a real 404, which is far easier to debug.
  • With this rewrite in place, every unknown URL returns 200. Does that matter, and can the client fix it?
    It creates soft 404s: crawlers and uptime checks see success for garbage URLs. A client-only app cannot fix it, because the shell's status line is sent before any JavaScript runs. If correct statuses matter you need server-side rendering or prerendering that resolves the route on the server and answers 404 itself.
  • How does hash-based routing avoid needing any server change?
    Everything after `#` is a fragment and is never sent in the HTTP request, so `/#/reports/42` is just a request for `/`. The server always has that. You trade the need for configuration against uglier URLs, loss of the fragment for real anchors, and tooling that ignores fragments.

saying these in an interview costs you the question

  • Claims pushState sends a request the server can route
  • Suggests redirecting all unmatched paths to the home page
  • Sets the 404 error document to index.html and calls it done
  • Lets the fallback swallow /api and asset requests
  • Thinks a client router can make unknown routes return 404

context