# Build an SPA

> Build an SPA: a worked guide from route registry through to a production build.

Source: [https://askrjs.com/docs/guides/build-an-spa](https://askrjs.com/docs/guides/build-an-spa)

Status: stable. Packages: @askrjs/askr.

**Published packages are authoritative.** Examples may lag behind a published contract. When guidance differs, verify the exports and TypeScript declarations in your installed package, then file an issue.

## Example

Create one route registry, mount it with createSPA, and keep browser-only services behind lifecycle-owned components.

```tsx
import { createSPA } from '@askrjs/askr/boot';
import { createRouteRegistry, route } from '@askrjs/askr/router';

const registry = createRouteRegistry(() => {
  route('/', DashboardPage);
  route('/projects/{projectId}', ProjectPage);
});

await createSPA({ root: document.getElementById('app')!, registry });
```

## Goal and architecture

A client-only SPA mounts once, owns routing in the browser, and never touches the server beyond static asset delivery and whatever APIs it calls at runtime. You build a registry with `createRouteRegistry(() => { ... })`, export it, and hand it to `createSPA({ root: '#app', registry })` — the registry is an ordinary value, so the path from route declaration to boot is something you can follow through the imports. `@askrjs/vite`'s `askr()` plugin is the only build-time piece you need — there's no server plugin, no `askrServer()` entry, and no document markers to manage.

## Implementation

Define routes with `route(path, Component)` or the nested `page()`/`group()`/`index()` helpers inside your registration function, and read the matched params straight from the component's first argument — Askr infers `RoutePathParams<Path>` from the literal path string, so `route('/users/{id}', UserPage)` gives `UserPage` a typed `id: string` without you writing the type by hand. State inside components is `state()`/`derive()`: read through the getter, write through the setter, no proxies. Wire up `<Link>` or `navigate()` for transitions and `Outlet()` in any layout component that needs to render nested routes.

## Failure states

Wrap render-time failures in `ErrorBoundary` with a `fallback` — it logs the underlying error and can reset when your app state changes, which matters in an SPA where a bad route param or a malformed API response shouldn't take down the whole shell. Register a `fallback(Component)` route for unmatched paths so users don't hit a blank screen on a typo'd URL. If a route has an `auth` policy and the resolver returns `deny`/`redirect`, make sure you've actually mounted with `auth` options on `createSPA`, or those policies never run.

## Verification

Click through every route you registered and confirm the URL bar, browser back/forward, and scroll position all behave — `scrollRestoration` on `SPAConfig` is opt-in, so verify it's doing what you expect rather than assuming a default. Force a render-time error in a leaf component and confirm the nearest `ErrorBoundary` catches it instead of a blank page. Finally confirm the registry you passed to `createSPA` is the one you actually edited — that is an import you can follow, which is why "routes work in dev but not in the built app" is a much rarer failure with an explicit registry than with an ambient one.

## Documentation navigation

[Previous](https://askrjs.com/docs/guides/index.md) | [Next](https://askrjs.com/docs/guides/add-ssr-to-an-spa/index.md)
