Skip to content

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 recognise a route it can render server-side, and serve index.html for it so a deep link resolves before the router has run.

One shell ​

The route table in src/App.jsx 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 the app's five destinations as inline links (behind a menu button and a sheet below md), 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.

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 five destinations don'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.

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 it 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 three different top paddings and three different bottom ones, none of which meant anything.

There are two widths:

WidthValueFor
wide (default)max-w-5xlListings, dashboards, the lesson and its side rail.
readingmax-w-3xlProse 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.

Two places opt out and say so where they do: the marketing hero (a full-bleed gradient) and the editor's panes. They still use PageBody's side padding, PAGE_GUTTER, which adds the safe-area insets on a phone turned sideways; see Mobile layout.

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 ​

RoutePageWhat it does
/HomeLanding page. Signed out: a marketing splash (animated floating words + feature blurbs). Signed in: a dashboard (your lessons, latest-lessons feed, your activity, activity from people you follow, notifications).
/editorEditorThe lesson builder. Two panes (section outline and document), the outline appearing once the page column has room for it. Preview toggles the document pane for 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/historyEditorThe version-history panel, over the editor.
/editor/variationsEditorThe variations panel, over the editor.
/editor/collaborateEditorThe live-collaboration panel, over the editor.
/editor/checkEditorThe lesson checks panel, beside the editor.
/hubLesson hubPublic gallery of published lessons (plus your own drafts), with search.
/hub/:idLessonThe lesson itself, with an "About" rail: author, ages, section count, fork lineage, and the print / Word / fork actions.
/hub/:id/practiceLesson (Practice)Interactive mode, full screen over the page.
/hub/:id/discussionLesson (Discussion)Comments and the star rating.
/hub/:id/proposalsLesson (Proposals)Changes proposed from other people's forks.
/hub/:id/proposals/:prIdLesson (Proposals)One proposal, read-only. Review & merge hands off to the editor; see below.
/hub/:id/historyLesson (History)The lesson's published commit timeline, read out of its packfile.
/users/:idUser profileA user's public profile: bio, follower/following counts, a Follow button, and published lessons.
/libraryOn this deviceThe lessons this device holds: open one, copy, rename or delete one, or start another. Works signed out.
/loginSign inMagic-link sign-in / account status.
/moderationModerationModerator/admin queue for reviewing reported content (gated to mods/admins).
/settingsSettingsAppearance (light/dark/system), display language, read-aloud preferences, this device's lessons, and the account's display name and bio. Works signed out.

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. The Worker's apps/api/src/routes/spa.js holds the same route table and decides what a path gets:

PathAnswer
A file in the buildThe file
A route in the tableindex.html, 200
Anything else, no extensionindex.html, 404 (NotFoundPage)
Anything else, with an extensiontext/plain, 404
A missing /docs/… pageVitePress's own 404 page, 404

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", 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 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>. The editor treats a panel name it doesn't recognise as "no panel open", so 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.

EditorShell mounts no chrome (AppShell is already above it) and exists only to hold the chunk boundary and the editor's own nested routes.

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.

They are real routes rather than a <Tabs> widget because that is what makes them shareable, back-button-correct and server-renderable.

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 is read-only and its Review & merge button navigates to /editor?pull=<id>&lesson=<lessonId>, exactly as the old stacked list did. See Proposed changes.
  • 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.

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

LinkWhat it does
?join=<code>Opens the live-collaboration panel on that invite.
?pull=<id>&lesson=<lessonId>Opens a proposed change 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.
?local=<id>Switches to one of the lessons on this device.
?new=1Starts a new lesson: what the header's New lesson button links to, since plain /editor resumes whichever lesson you last had open.

The first two are consumed once and then simply sit in the URL; the last two 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.

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/ssr.js have to agree; both carry a comment saying so.

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

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, /moderation, /login, /oauth/authorize. History is lazy because it is the only reader-facing page that needs isomorphic-git and LightningFS (~200 KB). The editor matters most: ~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.

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; 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 in KV (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, a small built-in word list is used instead. Feature illustrations live under apps/web/public/home/ (a missing file degrades to a labelled placeholder; see Feature screenshots below).
  • Signed in: a dashboard showing the user's own lessons (drafts included, from GET /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 Following), 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.

FileShows
feature-editor.jpgThe editor with a full lesson loaded: header, sections sidebar, a block
feature-ai.jpgThe "Suggest text with AI" dialog (Generate with AI, then Text)
feature-images.jpgThe "Search images" dialog with Wikimedia Commons results for "Penguin"
feature-hub.jpgThe top of the lesson hub list
feature-collab.jpgThe "Collaborate on this lesson" dialog
feature-export.jpgPage 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 sidebar. 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 button. 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).
  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.

Copyright © 2026 Spelling Creator.