# Single-page application

## What it is

A single-page application, also written single-page app and usually shortened
to SPA, is a web application that loads one HTML document and then changes that
document with JavaScript instead of asking the server for a new page. Clicking a
link inside it does not unload the page. A script catches the click, fetches
whatever data the next view needs, rewrites part of the DOM and updates the
address bar, so the URL still says where the reader is.

No standard owns the term, and the people who build the tools admit as much.
Next.js opens its guide to the subject with “The definition of a SPA varies.”
and then fixes one for its own use, which it calls a strict SPA. It has two
traits. A single HTML file serves the whole app, and JavaScript in the browser
takes care of every route, every page transition and every request for data. And
the page never reloads in full: when the route changes, scripts on the client
rewrite the DOM of the page that is already open and load data when it is
wanted, instead of asking the server for a fresh document. This article uses
that definition because it is the most direct one on offer and names the
property everything else depends on, a document that is never replaced.

The browser parts are standard. The HTML Standard's History interface gives a
page `history.pushState()`, which adds a new entry to the session history and
sets the URL without loading anything, and `history.replaceState()`, which
rewrites the current entry instead. When the reader goes Back or Forward between
those entries, the browser fires a `popstate` event at the window and leaves the
redrawing to the page. The newer Navigation API, which the standard presents as
the successor to the classic location and history APIs, works from the other
end: a `navigate` event fires when a navigation begins, and its `intercept()`
method turns that navigation into a same-document one that the page handles
itself.

The opposite is the ordinary multi-page website, where every link asks the
server for a fresh document and the browser throws the old one away. Whether a
page's HTML is made once by a static site generator or on each request by
server-side rendering is a separate question, about how the HTML is produced
rather than what happens after it arrives. Next.js blurs the line on purpose:
its static export writes an HTML file for every route, and moving between them
still happens on the client.

## Why it matters

Because the document is never replaced, whatever lives in it survives a click:
a half-filled form, a playing video, the scroll position of a side panel, data
already fetched. Only the data the next view needs has to cross the network,
not a whole new page with its header, styles and scripts. A change of view can
be as small as rewriting one region of the page.

Next.js names the costs in the same breath as the definition. A strict SPA often
has to load a large amount of JavaScript before the page can be interactive, and
client data waterfalls, where one request can start only after another has come
back, are hard to manage. Anything that reads the page without running scripts
pays as well. With one HTML file behind every route, a crawler that does not run
JavaScript gets the same nearly empty document whatever URL it asks for. Next.js
makes the same point about its own browser-only components: anything that
indexes the page without running scripts gets the fallback, not the component.

Its answer is to keep the client-side navigation and move the rest. Next.js
splits JavaScript bundles automatically and generates multiple HTML entry points
into different routes, so a visitor does not load code a route does not need.
Its `next/link` component prefetches routes, which makes route changes feel as
immediate as those in a fully client-rendered SPA while keeping the
application's routing state in the URL, where it can be linked to and shared. A
static export goes further and writes a fully rendered page for each route, so
visitors get content without waiting for the client bundle, while client-side
transitions between pages stay as quick as in a strict SPA.

Nor is the choice made once and for all. Next.js describes a project that starts
as a static site, or even as a strict SPA rendered entirely on the client, and
adds server features as it grows, and it says an existing SPA can move across
without being rewritten from scratch.

## What it looks like in practice

The router below, written for this article, is the smallest working version of
the idea. It assumes the page has a `<main>` element and that the server
answers every app path with the same HTML document. The two views stand in for
whatever the app really shows.

```js
// router.js
const views = {
  "/": { title: "Orders", text: "Your recent orders." },
  "/settings": { title: "Settings", text: "Delivery preferences." },
};
const main = document.querySelector("main");

function show(path, moveFocus) {
  const view = views[path] ?? views["/"];
  const heading = document.createElement("h1");
  const body = document.createElement("p");
  heading.textContent = view.title;
  heading.tabIndex = -1;
  body.textContent = view.text;
  main.replaceChildren(heading, body);
  document.title = view.title;
  if (moveFocus) heading.focus();
}

document.addEventListener("click", (event) => {
  const link = event.target.closest("a[href]");
  const modified = event.metaKey || event.ctrlKey || event.shiftKey;
  if (!link || link.origin !== location.origin) return;
  if (modified || event.button !== 0) return;
  event.preventDefault();
  history.pushState(null, "", link.href);
  show(location.pathname, true);
});

window.addEventListener("popstate", () => show(location.pathname, true));
show(location.pathname, false);
```

The click handler takes over plain left clicks on links to the same origin and
lets modified clicks through, so opening a link in a new tab still works.
`preventDefault()` stops the browser's own navigation, and `pushState()` records
a new session history entry with the link's URL; its middle argument is there
only for historical reasons, which is why it is an empty string. `show()`
replaces the view, sets the document title and moves focus to the new heading,
which a `tabIndex` of `-1` makes focusable from script while keeping it out of
the Tab order. The `popstate` listener redraws the matching view when the
reader goes Back or Forward, and the last line draws whichever URL the page was
opened at, which only works if the server sends the same document for
`/settings` as for `/`. A real app would also show its own not-found view
instead of quietly falling back to the start page, and would leave links to
files and to other parts of the site alone.

The company's Next.js 16 frontend shows the cost side of this on a real product.
[Its portfolio entry](/portfolio/built-the-next-js-16-frontend-ssr-ssg-and-csr/)
describes an authenticated dashboard that had to feel quick and stay genuinely
interactive, and names what happens when everything is fetched on the client:
the first load drags, and the N+1 pattern appears, with every component waking
up and sending its own request until a single page becomes a cascade of round
trips. That is the client data waterfall the Next.js guide warns about, met in
practice. The fix kept client-side interactivity where it mattered and moved
the first fetch to the server. On the dashboard and the big directory listings,
a factory called `createPrefetchedServerPage` hands each page to the client
with its data already in place. The entry is candid about the rest: the other
authenticated pages are still rendered on the client and wait their turn, so
it describes a migration that works and has an obvious next page, not one that
is complete.

## Where it goes wrong

**The Back button leaves the app.** If a script swaps views without calling
`pushState()`, the session history has no entry for them. Back skips every view
the reader saw and leaves the app altogether, and a reload lands on whatever
URL the app was first opened at. Push an entry for each view a reader would
call a page, use `replaceState()` for changes that should not become steps of
their own, such as a filter being typed, and handle `popstate`. Next.js argues
for keeping routing state in the URL for the same reason: it is what lets a
view be linked to and shared.

**A deep link meets a server that knows one file.** In a strict SPA one HTML
file serves every route, but the address bar shows `/settings`, and that is
what the browser asks for on a reload or when someone opens a shared link. A
server that only has `index.html` answers with a 404. Either configure the
server to answer every app path with the document, or generate a file per
route. Next.js's static export does the second: its `out` folder holds files
such as `index.html`, `404.html` and `blog/post-1.html`, and its guide shows an
nginx rule, `try_files $uri $uri.html $uri/ =404;`, that maps a clean URL onto
the matching file.

**Focus stays where the old view was.** `pushState()` changes the session
history and the URL and nothing else. After a script swaps the view, keyboard
focus stays on the link that was clicked, or is lost if the element holding it
was removed, and nothing tells assistive technology that a new view has arrived
unless the page does. Set the title and move focus to the new view's heading,
as the example does. The Navigation API takes on more of this itself. A
navigation handled with `intercept()` resets focus by default, to the first
element with `autofocus` or else to the body, and once the handler finishes,
the browser signals completion to the user, which the standard says includes
assistive technology. An app that places focus itself opts out with
`focusReset: "manual"`.

**Everything is paid for up front.** A strict SPA often has to load a large
amount of JavaScript before anything is interactive, and its first data requests
start only once that code runs. Split the bundle by route, so that a visitor
downloads only what the current view needs, and start the first requests on the
server. The Next.js pattern for this fetches data in a Server Component without
awaiting it and passes the promise to a Client Component, which unwraps it with
`use()`. Because the request begins before the rest of the app renders, the
guide says, the response can start streaming at once, and the client is spared a
waterfall of requests, each waiting for the one before it.

## Further reading

- [How to build single-page applications with Next.js](https://nextjs.org/docs/app/guides/single-page-applications)
  — the strict SPA definition, what it costs, and the patterns Next.js uses to
  keep client-side navigation while moving work to the server.
- [HTML Standard: The History interface](https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-history-interface)
  — `pushState()`, `replaceState()` and how session history entries are added
  and replaced.
- [HTML Standard: Navigation API](https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigation-api)
  — the `navigate` event, `intercept()`, and the focus and scroll handling that
  comes with it.
- [How to create a static export of your Next.js application](https://nextjs.org/docs/app/guides/static-exports)
  — one HTML file per route, what a static export cannot do, and serving clean
  URLs with nginx.

<https://engineer.company/glossary/single-page-application/>
