---
url: https://spellingcreator.org/docs/developers/web-app/pages-and-routing.md
---

# Pages & routing

The app is a single-page app with real-path client-side routes (served by
`BrowserRouter`, not hash routes). Every page has a genuine URL like `/hub/:id`,
which is what lets the Worker recognize a route it can [render server-side](./server-rendering.md),
and serve `index.html` for it so a deep link resolves before the router has run.

Routing is set up in `apps/web/src/main.jsx` (`BrowserRouter` + `SsrProvider` +
`AuthProvider`, wrapped in a `DisplayNameGate`) and the route table is in
`apps/web/src/App.jsx`.

## One shell

The route table puts every page inside a single layout route, `AppShell`, and
that is the whole of the app's chrome: one slim sticky header (`AppHeader`) with
the page below it.

The header carries three destinations as inline links (**Home**, **Lesson
hub** and **On this device**), which move behind a menu button and into a sheet
below `md`. Then come the **New lesson** action and a right-hand cluster of
utilities: install, the light/dark toggle, settings, notifications, and the
account menu. Pages that are *inside* something (a lesson under the hub, a
document in the editor) pin a second, contextual bar (`PageBar`) directly
beneath it, carrying a breadcrumb trail and that page's actions. Plain pages
don't render one; their title lives in their body. See
[Mobile layout](./mobile-layout.md#the-nav-is-a-sheet-below-md) for what the
header drops at narrow widths.

The shell has been through three shapes, and the history is why this one is so
small. Pages first rendered their own heavy `--primary` header over their own
`mx-auto max-w-*` column, restating the whole nav on every page. Then a
collapsible sidebar absorbed the nav and shrank the top bar to a breadcrumb,
which fixed the header and created a 16rem column of mostly empty chrome beside
every content page, since a handful of destinations doesn't fill a sidebar.
They fit in one row, so now there is one header, no sidebar, and every page gets
the full width of the viewport (which the homepage hero and the editor, in
particular, put to use).

Pages that need more room ask the **container**, not the shell; see
[Laying out against the container](#laying-out-against-the-container).

There is exactly one route outside the shell, and it is deliberate rather than
left over:

* `/oauth/authorize`: the MCP consent screen, reached by redirect from a
  third-party client (`apps/api/src/routes/oauth.js`). App navigation on a grant
  screen is an invitation to wander off in the middle of one.

`/` is inside the shell like everything else, both signed in (a dashboard) and
signed out (the marketing splash). The splash briefly had a header of its own,
on the grounds that a first-time visitor doesn't need navigation; that was a
defensible thing to say about the splash and the wrong thing to do to the app,
because it meant one URL rendered two different chromes depending on who you
were.

## The content column

`components/layout/PageBody.jsx` is the column every page renders into, and it
exists for the same reason `AppShell` does: "the content column" had drifted
into five different things across the pages, at different top and bottom
paddings, none of which meant anything.

There are two widths:

| Width            | Value       | For                                                                            |
| ---------------- | ----------- | ------------------------------------------------------------------------------ |
| `wide` (default) | `max-w-5xl` | Listings, dashboards, the lesson and its side rail.                            |
| `reading`        | `max-w-3xl` | Prose people read or write: comments, a proposal's description, a commit list. |

The `reading` width is why the page getting wider did **not** make lesson text
wider: a line of text set to the full width of a desktop screen is harder to
read, not easier. The lesson's sticky tab bar needs the column's width but
can't be the column, so it imports `PAGE_WIDTHS` rather than restating the
number. Both Suspense fallbacks (AppShell's and the route-level one in
`App.jsx`) simply render a `PageBody` holding a skeleton.

Two places opt out and say so where they do: the marketing hero (a full-bleed
gradient) and the editor's panes, which fill the page column at every size.
They still use `PageBody`'s side padding, `PAGE_GUTTER`, which adds the
safe-area insets on a phone turned sideways; see
[Mobile layout](./mobile-layout.md#the-utilities).

## Laying out against the container

`AppShell`'s `<main>`, the page column, is a named container
(`@container/page`), and anything that lays itself out against available space
keys off that rather than off a `lg:`/`xl:` viewport breakpoint. The editor's
outline pane appears once the page column passes 52rem; the lesson's "About"
rail moves alongside the lesson at the same 52rem.

The distinction dates from the sidebar era, when the page column was 13rem
narrower or wider depending on a collapse toggle and a viewport breakpoint had
no way to know which. With the sidebar gone the column and the viewport
currently agree, but the keys stay written against the container, so the next
thing that narrows the column (a future rail, a split view) costs nothing in
these layouts.

## The routes

| Route                      | Page                    | What it does                                                                                                                                                                                                                                                                               |
| -------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/`                        | **Home**                | Landing page. Signed out: a marketing splash (animated floating words and feature blurbs). Signed in: a dashboard (your lessons, latest-lessons feed, your activity, activity from people you follow, notifications).                                                                      |
| `/editor`                  | **Editor**              | The lesson builder. Two panes (section outline and document), the outline appearing once the page column has room for it. **Preview** toggles the document pane to the reader's view of the lesson.                                                                                        |
| `/editor/lessons`          | (redirect)              | Where the library used to be, as a dialog over the editor. Redirects to `/library`.                                                                                                                                                                                                        |
| `/editor/history`          | **Editor**              | The version-history panel, over the editor.                                                                                                                                                                                                                                                |
| `/editor/variations`       | **Editor**              | The [variations](./variations.md) panel, over the editor.                                                                                                                                                                                                                                  |
| `/editor/collaborate`      | **Editor**              | The [live-collaboration](./live-collaboration.md) panel, over the editor.                                                                                                                                                                                                                  |
| `/editor/check`            | **Editor**              | The [lesson checks](./lesson-checks.md) panel (**Check lesson**), beside the editor.                                                                                                                                                                                                       |
| `/hub`                     | **Lesson hub**          | Public gallery of published lessons (plus your own drafts), with search.                                                                                                                                                                                                                   |
| `/hub/:id`                 | **Lesson**              | The lesson itself, with an "About" rail: author, section count, ages, published date, fork lineage, and the **Print PDF** / **Download Word** / **Fork** actions.                                                                                                                          |
| `/hub/:id/practice`        | **Lesson (Practice)**   | [Interactive mode](./interactive-mode.md), full screen over the page.                                                                                                                                                                                                                      |
| `/hub/:id/discussion`      | **Lesson (Discussion)** | Comments and the star rating.                                                                                                                                                                                                                                                              |
| `/hub/:id/proposals`       | **Lesson (Proposals)**  | [Changes proposed](./pull-requests.md) from other people's forks.                                                                                                                                                                                                                          |
| `/hub/:id/proposals/:prId` | **Lesson (Proposals)**  | One proposal, with its changes worked out in the browser. **Review & merge** and **Try it in a variation** hand off to the editor; see below.                                                                                                                                              |
| `/hub/:id/history`         | **Lesson (History)**    | The lesson's published commit timeline, read out of its packfile.                                                                                                                                                                                                                          |
| `/users/:id`               | **User profile**        | A user's public profile: bio, follower/following counts, a Follow button, and published lessons.                                                                                                                                                                                           |
| `/library`                 | **On this device**      | The [lessons this device holds](./local-lessons.md): open one, copy, rename or delete one, or start another. Works signed out.                                                                                                                                                             |
| `/login`                   | **Sign in**             | Sign-in (magic link and emailed code, or username and password, depending on the instance's auth mode) and account status.                                                                                                                                                                 |
| `/moderation`              | **Moderation**          | Moderator/admin queue for reviewing reported content (gated to mods/admins).                                                                                                                                                                                                               |
| `/settings`                | **Settings**            | Appearance (light/dark/system), display language, [read-aloud](./interactive-mode.md) preferences, the **This device** card (install, this device's lessons, [downloaded AI models](./pwa-and-offline.md#downloaded-ai-models)), and the account's display name and bio. Works signed out. |
| `/oauth/authorize`         | **MCP consent**         | Outside the shell; see [One shell](#one-shell).                                                                                                                                                                                                                                            |

### Unknown paths

An unknown path is a **404**, status and page both. `NotFoundPage` renders in
the usual chrome and offers the home page and the hub; it replaced a
`<Navigate to="/">` that quietly turned every dead link into the homepage,
telling nobody anything had gone wrong.

The status matters as much as the page, and it is the host's to send.
`apps/api/src/routes/spa.js` holds **the same route table** (`APP_PATHS`) and
`serveApp()` decides what a path gets. Both hosts end their frontend
fall-through there (the Worker's `handleFrontend` in `routes/render.js` and the
Node entry in `apps/api/src/node/server.js`), so a path answers the same way
whoever is serving it:

| Path                             | Answer                               |
| :------------------------------- | :----------------------------------- |
| A file in the build              | The file                             |
| A route in the table             | `index.html`, `200`                  |
| Anything else, with an extension | `text/plain`, `404`                  |
| A missing `/docs/…` page         | VitePress's own 404 page, `404`      |
| Anything else, no extension      | `index.html`, `404` (`NotFoundPage`) |

Before this, the host answered every unmatched path with `index.html` and a
`200` (Cloudflare's `not_found_handling: "single-page-application"`). That is a
**soft 404**, a page insisting it exists, which is how a crawler ends up
indexing an unlimited supply of junk URLs, and it also meant a client probing
`/.well-known/anything.json` got a successful response containing an HTML
document. `not_found_handling` is now `"none"` in `apps/api/wrangler.jsonc`, and
the decision is made in code that knows the routes.

So **the table in `spa.js` has to stay in step with `App.jsx`**, the same
standing obligation `ssr.js` and `WORKER_PATHS` already carry. The failure mode
is deliberately mild: a route added to `App.jsx` and forgotten there is still
served the shell and still renders correctly, it just carries a `404` status.

One caveat when you go looking for that status in a browser: a returning visitor
with the [service worker](./pwa-and-offline.md) installed is answered from the
precached shell (a `200`) without the Worker being asked at all. The page is
the same; only the status differs, and the audience the status is for (crawlers)
does not run service workers. Test it with a hard reload or a private window.

Two things are deliberately **not** 404s:

* **An unknown `/editor/<something>`.** `EditorShell` has a single splat route,
  and `EditorPage` reads the panel name from the path, treating one it doesn't
  recognize as "no panel open". A stale link shows the editor rather than
  bouncing you out of it. `/editor/*` is a wildcard in both route tables.
* **A lesson that doesn't exist.** `/hub/<deleted-id>` *is* a route, so it is
  served `200` and the app reports the miss once it has tried to fetch;
  see [Server rendering](./server-rendering.md#how-it-fits-together).

`EditorShell` mounts no chrome (`AppShell` is already above it). It holds the
chunk boundary, and its one route keeps `EditorPage` in a single position in
the element tree, so moving between `/editor` and `/editor/history` doesn't
remount the editor and throw away its document, repository and collaboration
session.

### A lesson's tabs

`src/pages/lesson/` holds one file per tab plus `LessonLayout.jsx`, which owns
the fetch, the identity header (title, author, rating) and every action on the
lesson as a whole. Tabs read the lesson through `useLesson()`, the layout's
outlet context, and never fetch it again. The tab labels are **Lesson**,
**Practice**, **Discussion**, **Proposals** and **History**.

They are real routes rather than a `<Tabs>` widget because that is what makes
them shareable, back-button-correct and [server-renderable](./server-rendering.md).

Two things deliberately did **not** become tabs:

* **Merging a proposal.** It is a genuine three-way merge against the lesson's
  git history, and that history lives in the editor's browser-side repository.
  `/hub/:id/proposals/:prId` shows the proposal's diff (it loads the git engine
  on demand to work it out) but doesn't merge. Its **Review & merge** button
  navigates to `/editor?pull=<id>&lesson=<lessonId>`, and **Try it in a
  variation** adds `&try=1`. See [Proposed changes](./pull-requests.md).
* **The editor's own history panel.** It reads *your* repository, the one your
  edits are committed to. The lesson page's History tab is a different thing
  built on the published packfile (`GET /git/:lessonId/pack`); it loads the git
  engine on demand, so isomorphic-git stays out of the bundle a reader
  downloads.

## Query-string deep links

These query strings deep-link into the editor rather than being routes of their
own:

| Link                                 | What it does                                                                                                                                                                                                                                 |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `?join=<code>`                       | Opens the [live-collaboration](./live-collaboration.md) panel on that invite.                                                                                                                                                                |
| `?pull=<id>&lesson=<lessonId>`       | Opens a [proposed change](./pull-requests.md) for review once the lesson it names has loaded; the lesson id is part of the link precisely so the review waits for the right one, rather than acting on whatever the editor already had open. |
| `?pull=<id>&lesson=<lessonId>&try=1` | The same, but lands the proposal on a variation of the reviewer's own instead of reviewing it into the lesson.                                                                                                                               |
| `?local=<id>`                        | Switches to one of the [lessons on this device](./local-lessons.md).                                                                                                                                                                         |
| `?new=1`                             | Starts a new lesson: what the header's **New lesson** button links to, since plain `/editor` resumes whichever lesson you last had open.                                                                                                     |

The `join` and `pull` links are consumed once and then simply sit in the URL;
`local` and `new` are stripped from it as they are read, because they are
instructions rather than state and a reload should not carry them out twice.
Opening an editor panel preserves the query string, so navigating to
`/editor/collaborate` never drops the invite that sent you there.

## Offline and the service worker

Once the PWA service worker is installed it resolves these routes itself, from
the precached `index.html`, which is what lets a deep link open with no network.
The paths the Worker answers instead (the server-rendered routes, `/docs`,
`/images/…`, the SEO and MCP OAuth endpoints) are excluded by name; see
[Installable app & offline use](./pwa-and-offline.md#navigation-fallback-and-the-paths-it-must-not-touch).

That exclusion covers the whole of `/hub/*`, lesson tabs included, so those
paths **must** stay matched by the server renderer. A path excluded from the
shell and unmatched by the Worker works online (it falls through to the SPA
shell) and fails offline. `WORKER_PATHS` in `apps/web/vite.config.js` and
`LESSON_PATH` in `apps/api/src/routes/spa.js` (which `ssr.js` imports) have to
agree; both carry a comment saying so.

## Which routes are lazy

`src/App.jsx` splits the route table deliberately rather than lazy-loading
everything:

* **Eager**: `/`, `/hub`, `/users/:id`, and `/hub/:id` with all its tabs except
  History. The server-rendered routes have to be in the bundle the client
  hydrates with; deferring them would trade a smaller download for a round trip
  on the pages where first paint matters most. `/` is the commonest entry point.
* **Lazy**: `/hub/:id/history`, `/editor`, `/library`, `/moderation`,
  `/settings`, `/login` and `/oauth/authorize`. None is server-rendered and none
  is reachable without a deliberate click. History is lazy because it is the
  only reader-facing page that needs isomorphic-git and LightningFS (about
  200 KB, by the comment in `App.jsx`). The editor matters most: about 6,000
  lines, and the only owner of Yjs, `lib0` and the collaboration client, none of
  which a reader of a lesson should download. `EditorShell` is lazy for that
  reason too; importing it eagerly from `App.jsx` would pull the editor straight
  back into the main bundle.

The server build goes one step further: `SSR_UNREACHABLE` in
`apps/web/vite.config.js` replaces the lazy pages that are never server-rendered
(and the export, git and on-device model engines) with a stub, so the Worker
doesn't carry chunks it can never run. See
[Server rendering](./server-rendering.md#build-order).

`AppShell` mounts a `Suspense` boundary of its own around its `<Outlet/>`. The
one in `App.jsx` sits *above* the layout routes, so a lazy page suspending there
unwinds past the shell and takes the header with it. The inner boundary keeps
the chrome on screen and replaces only the body.

Tiptap/ProseMirror stays eager on purpose: `CommentsSection` uses
`RichTextInput` on the public lesson page, so it isn't editor-only.

## Home page

The home page (`src/pages/HomePage.jsx`) has two faces, chosen from the auth
state. Both render inside the app's shell, under the same header as everywhere
else; only the body differs:

* **Signed out**: a hero whose backdrop is real spelling words drifting upward
  (built with [tsParticles](https://particles.js.org); see
  `src/components/FloatingWords.jsx`), followed by alternating feature blurbs.
  The words come from the Worker's `GET /spelling-words.json`, an aggregate of
  every spelling word taught across the published hub lessons, rebuilt at most
  once every two days and cached (`apps/api/src/routes/spelling-words.js`).
  A spelling row can hold a phrase rather than a single word ("ice cream"), and
  those animate badly (the shape scales text by character count, so a phrase
  renders tiny and stretched), so `@spelling-creator/core/spellingWords` drops
  any entry containing whitespace and only single words reach the animation.
  If that fetch fails, or there is no API configured, a small built-in word list
  (`FALLBACK_WORDS`) is used instead. Feature illustrations live under
  `apps/web/public/home/`; see [Feature screenshots](#feature-screenshots)
  below. Between the feature rows and the closing call to action sits a short
  section headed "For Spelling, however you practice it" (strings under
  `marketing.spelling` in `locales/en/home.json`). It says that Spelling is an
  umbrella over practices that aren't standardized (S2C, RPM, Spellers Method,
  and practitioners with no label), so the editor's defaults are a starting
  point. Its link (`SPELLING_DOCS_URL`) is a plain `<a>` styled with
  `Button variant="link"` rather than a router link, since the docs are a
  separate static site. In production it's `/docs/intro` on the same origin, so
  a self-hosted copy links to its own docs; the Vite dev server serves no
  `/docs`, so in dev it points at the published site. It opens in a new tab,
  because the service worker leaves `/docs` alone: offline, the failure lands
  in that tab instead of replacing the installed app.
* **Signed in**: a dashboard showing the user's own lessons (drafts included,
  from `GET /lessons/mine`; this list used to be a sidebar group, and a panel of
  your work is a better home for it than global chrome), the hub's
  latest-lessons Atom feed and the user's own activity feed (both parsed
  client-side from Atom with `DOMParser`, reusing the same `feed.xml` /
  `profiles/:id/feed.xml` endpoints the "RSS" links point at), a **"From people
  you follow"** feed (from the Worker's `GET /following/activity`; see
  [Profiles](./profiles.md)), plus a roomier list of the user's notifications.

### Feature screenshots

Each signed-out feature row shows a screenshot from `apps/web/public/home/`. The
row draws it at 16:10 with `object-cover`, so anything in another shape gets
cropped. Take new shots at 16:10 to avoid that.

| File                 | Shows                                                                    |
| -------------------- | ------------------------------------------------------------------------ |
| `feature-editor.jpg` | The editor with a full lesson loaded: header, sections outline, a block  |
| `feature-ai.jpg`     | The "Suggest text with AI" dialog (Generate with AI, then Text)          |
| `feature-images.jpg` | The "Search images" dialog with Wikimedia Commons results for "Penguin"  |
| `feature-hub.jpg`    | The top of the lesson hub list                                           |
| `feature-collab.jpg` | The "Collaborate on this lesson" dialog                                  |
| `feature-export.jpg` | Page one of a lesson's "Print PDF" export, on the app's background color |

The docs site's home page uses one more, `apps/docs/docs/public/img/screenshot.jpg`:
the editor's main column only (the title card and the first section), without
the header or outline. The docs hero draws it at about 260px wide, so a
whole-app view would be too small to read.

To retake them:

1. Use the live site, signed out, in light mode, at a device scale factor of 2.
   A 1120px-wide viewport fits the editor without crowding it.
2. For the editor shots, use a real hub lesson. Fetch its JSON from
   `GET /lessons/:id`, save the `doc` field to a `.json` file, and load it with
   the editor's **Import JSON** menu item. That keeps the lesson in the browser
   only. The current set uses "Penguins".
3. For the AI dialog, only open it. Don't press Generate. Pixabay search needs
   Turnstile, but Wikimedia Commons doesn't, so search there for the image
   dialog. Signed out, the Collaborate dialog shows a sign-in notice and
   disables its controls, so before taking that shot, edit the page in DevTools
   to look signed in: remove the notice and the `disabled` attribute from the
   Start button, the join-code input and the Join button. Those are the only
   differences signing in makes (see `renderLanding` in
   `CollaborateDialog.jsx`). Or take that one against the
   [stub API](../stub-api.md) instead, signed in as `maya`, where the
   dialog is the real signed-in one and needs no editing.
4. Crop the dialogs to 672x420 CSS pixels around the dialog, the editor to
   1120x700 from the top of the page, and the docs shot to about 870x625 around
   the main column.
5. Save the homepage shots as JPEG at 1008x630 (quality around 75) and the docs
   shot at 1100 wide. That's sharp at the size the row draws them (about 490px
   wide on desktop) and keeps the six homepage files around 500 KB in total.
