diff --git a/CLAUDE.md b/CLAUDE.md
index 55d1960d0..c955df077 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -86,8 +86,10 @@ path in silence.
`WIKI` global (config + logger + lazy `ensureDb()`) and dynamically imports the task.
- `base.yml` — system defaults for every config key. Do not edit as a user-facing config; it defines
the shape merged with `config.yml` and the db `settings` table.
-- `helpers/` — small pure utilities (`common.ts`, `config.ts`), plus `storageFiles.ts`, which is the
- file-tree half of the storage modules that address content by path (see [Storage targets](#storage-targets)).
+- `helpers/` — small pure utilities (`common.ts`, `config.ts`), plus two that are not: `storageFiles.ts`,
+ the file-tree half of the storage modules that address content by path (see
+ [Storage targets](#storage-targets)), and `appShell.ts`, which describes a page in the HTML document
+ served for it (see [The app shell and SEO](#the-app-shell-and-seo)).
- `types/` — ambient declarations: `global.d.ts` (the `WIKI` global) and `fastify.d.ts` (session +
route-permission augmentations).
- `locales/` — `en.json` source strings (CrowdIn-managed) + `metadata.js` language table (the one
@@ -785,6 +787,89 @@ store; no SVG is ever written into content.
- Picking an icon calls `POST /_api/icons/materialize`, which is what guarantees the wiki can serve it
afterwards without the Iconify API.
+### The app shell and SEO
+
+The compiled SPA is one document for every path on the wiki, served by the `setNotFoundHandler` in
+`index.ts` — which is the fallback rather than a route because a page lives at any path a user cares
+to give it, and the frontend's router is what resolves one.
+
+That document says nothing about the page at its URL until a browser has run it — a `
` reading
+`Wiki.js` for every page of every wiki — and **plenty of clients never run it**: a chat client
+building an unfurl card, an AI crawler (GPTBot, ClaudeBot, PerplexityBot and friends fetch raw HTML
+and render nothing), a search engine that does not render, a reader with JavaScript off.
+`helpers/appShell.ts` is what puts something in the document, and `renderAppShell` is the whole of
+the decision.
+
+**There is no server-side rendering here, and none is wanted.** A page's HTML is already a string in
+`pages.render`, produced once in the editor's browser at save time — so describing a page is reading a
+row and two string insertions, not running Vue, a component tree or a second build. Anything that
+proposes rendering on the server is solving a problem this schema does not have.
+
+**Every request gets a head describing the page at its URL.** What separates the two kinds of
+document is whether the client will run the app, and each has its own function:
+
+- **`fragmentsForCrawler`** — for a client that will not. The PUBLIC's view of the page
+ (`pages.describePageForPublic`, which is `listForSitemap`'s question asked of one page): a head, the
+ page's markup appended, and 404 where the public may read nothing there. What goes in is what the
+ GUESTS group may read and nothing else, which is what makes it the same document for whoever asked
+ — and therefore the only half that is cached and handed on.
+- **`fragmentsForBrowser`** — for a browser that will. The head alone, describing the page as THAT
+ requester may see it (`pages.describePageForRequest`), which makes exactly the cut the page route
+ makes: `read:pages` per path, and any signed-in session sees an unpublished page while an API key is
+ answered as the public is. Never cached, because the answer is one reader's. It carries no body (the
+ app is about to draw the page properly, so a copy is bytes on every hard navigation for content the
+ browser discards), never 404s (whether a path this reader may create is empty is the app's own flow
+ to present) and adds no page-specific `noindex`.
+
+ It matters even though the app sets the title itself a moment later: the document's own title is
+ what the tab reads while the bundle loads, and what a bookmark or a history entry made before boot
+ finishes keeps for ever.
+- **The head is columns, not prose**: `` composed exactly as `MainLayout.vue` composes it so
+ the tab does not jump when the app boots, `description`, a canonical link, `hreflang` alternates
+ from the page's locale group, and the `og:`/`twitter:` pair — which is what a link pasted into
+ Slack or Discord reads, and the surface this was most visibly missing.
+- **The body is the stored render with everything that would *run* taken out** — `