Skip to content

Prerender the static pages so they read without JavaScript - #20

Merged
cdeutsch merged 7 commits into
mainfrom
claude/static-pages-no-js-2810af
Nov 12, 2026
Merged

cdeutsch merged 7 commits into
mainfrom
claude/static-pages-no-js-2810af

Conversation

@cdeutsch

Copy link
Copy Markdown
Contributor

Privacy, Terms, Support and Security were blank with JavaScript off: Hosting answers every path with index.html, whose #root stays empty until the bundle runs. Crawlers and link previews that run nothing also saw the front page's title on all four.

What changed

  • Build-time prerender. npm run build now ends with npm run prerender. It builds src/prerender.tsx for Node and writes dist/<page>.html for each page in src/staticPages.tsx: the built index.html with the page in #root, plus the page's own <title>, og:title and og:url. App routes from the same list.
  • Hosting. Each page has its own rewrite in firebase.json, ahead of the ** catch-all. The script fails the build if one is missing. I didn't use cleanUrls: it would redirect /index.html to /, and the site's image page is /index.html?view=….
  • No Firebase in the static pages. Firebase Auth started in Node keeps the script from exiting, so the build hung. useAuth is now split from AuthProvider, and MIN_PASSWORD_LENGTH moved to config/password.ts. The script fails with a message if the renderer ever imports Firebase again.
  • Taking over with JS on. On a prerendered page, main.tsx waits for Firebase to say who is signed in, then mounts with createRoot. AuthProvider starts from that answer, so the page never blanks. The reader's scroll position is carried over, #fragment landings included, and ScrollToTop now scrolls only when the app moves to another page.

The front page is not prerendered. Every unknown path is served index.html, so filling that file would show the landing page on /links and the other routes until the bundle loaded.

For the reviewer

  • Signed-in visitors: they get a signed-out first paint, then the nav once the app loads, as they do today. I didn't use hydrateRoot: a signed-in visitor's first render never matches the prerendered markup, so React would report an error and render from scratch anyway.
  • Keyboard focus is not carried over for someone who tabbed in before the bundle loaded.
  • Hosting emulator in worktrees: it answers 404 for every path when dist/ is under a dot-folder like .claude/worktrees/. docs/web.md says how to preview around it.
  • Merging deploys apps/web and the firebase.json rewrites to production.

Testing

  • npm run lint, npm run test:keys (67), npm run build, npm run build:chrome and npm run test:rules (172) pass. The rules tests ran before the review fixes, which don't touch rules.
  • In the Hosting emulator with the real rewrites:
    • The four pages are served filled in. /, /links, unknown paths and /index.html?view= still get the empty shell.
    • A copy with the script removed renders fully styled.
    • With JS on, the page text never disappears while React takes over.
    • /terms#refunds keeps its place, and in-app links still start at the top.
  • Every CSS-module class in the prerendered HTML exists in the client stylesheet.
  • Not tested: loading a prerendered page while signed in. The emulator setup had no sign-in.

🤖 Generated with Claude Code

cdeutsch and others added 7 commits November 12, 2026 14:55
The Security page imported `MIN_PASSWORD_LENGTH` from `services/signIn`, and `Layout` reads `useAuth` from a module that also holds the provider, so rendering either pulled in the Firebase SDK and started Auth. That is harmless in the browser, but it rules out rendering those pages in Node at build time: started there, Firebase Auth keeps the process from exiting.

`useAuth` and its context stay in `contexts/AuthContext.tsx`, now type-only on Firebase, and `AuthProvider` moves to `contexts/AuthProvider.tsx`. The password rule moves to `config/password.ts`. Behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every path on the site was answered with `index.html`, whose `#root` is empty until the bundle runs, so with JavaScript off the terms, privacy, support and security pages were blank. Crawlers and link previews that run nothing also saw the front page's title on all of them.

The build now renders those pages to HTML. `npm run prerender` (the end of `npm run build`) builds `src/prerender.tsx` for Node and writes `dist/<page>.html` for each entry in `src/staticPages.tsx`: the built `index.html` with the page in `#root` and its own title and `og:url`. `App` routes the same list. Each page has a rewrite of its own in `firebase.json` ahead of the catch-all, and the script fails the build when one is missing, or when the renderer imports Firebase (which would hang it). `cleanUrls` would avoid the list but redirects `/index.html`, the site's image page, to `/`.

With JavaScript on, `main.tsx` waits for Firebase to report who is signed in before mounting, and `AuthProvider` starts from that answer. Mounting at once rendered the provider's empty first frame over the page, blanking it briefly. The front page is left out: filling `index.html` would show it on every route until the bundle loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment said `Layout` still imports the Firebase config and that this was harmless. It has not since `AuthProvider` moved out of `AuthContext`, and the prerender script fails the build when the renderer imports Firebase, so the sentence read as permission for the very thing the guard forbids. It now says what is true and why the guard exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The prerender set `<title>` and `og:url` but left `og:title` as the front page's headline. Slack, iMessage and X read `og:title` before `<title>`, so a shared `/terms` link still previewed as the front page, now under the Terms URL, and the docs claimed otherwise. `og:title` now carries the page's title.

The descriptions stay the product's on purpose: they say what the page belongs to, which is what a card for the terms needs. `fill` takes a pattern so the script does not repeat the front page's headline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`AuthProvider` set `authKnown` in its own `authStateReady()` callback and `main.tsx` mounted in another. The provider started from the known answer only because its module registered first. Lazy-load `App` or reorder the imports and `mount` would run with the flag still false, bringing back the blank frame over the prerendered page with nothing failing to say so.

`AuthProvider` now exports the promise that sets the flag, and `main.tsx` waits on that one, so the order holds by construction. `main.tsx` no longer imports the Firebase config itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment said `hydrateRoot` could not work because the first render is `AuthProvider`'s empty one. That stopped being true when the provider began starting from the known answer, and a comment recording a rejected alternative on a false premise invites someone to rule it out, or back in, for the wrong reason. The reason that holds is the signed-in visitor, whose nav and menu never match the signed-out markup, so hydration would fail for them and render from scratch with an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A prerendered page can be read and scrolled before the bundle loads, and `/terms#refunds` lands on its section at once. The page scrolls inside `#layout-border`, which `createRoot` replaced with a new element at the top, and then `ScrollToTop`'s mount effect scrolled to the top again, so the reader was thrown back to the start.

`ScrollToTop` now scrolls only when the location changes from the one it mounted on, kept in a ref because StrictMode runs the effect twice. `mount` reads the offset, commits the first render with `flushSync`, and puts the offset back. Keyboard focus is still lost for someone who tabbed in before the bundle loaded: carrying it over would mean finding the same element in the new tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the web The web app and the Chrome extension (apps/web) label Nov 12, 2026
@cdeutsch
cdeutsch merged commit 8397b4a into main Nov 12, 2026
8 checks passed
@cdeutsch
cdeutsch deleted the claude/static-pages-no-js-2810af branch November 12, 2026 21:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

web The web app and the Chrome extension (apps/web)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant