add-route
This SPA has a dual shell with a third Layout-less group. Routes go in one of three tiers — picking the wrong one breaks Lighthouse on the homepage, unnecessarily ships Redux to a static page, or paints content behind the fixed navbar.
Decision
Lightweight (default — no Redux, no RTK Query, no Web3, no heavy CMS / LiveKit deps):
- Marketing pages, legal pages, sign-in landing, brand, content, ethics, help, press, discord, report, etc.
- Data via
useSyncExternalStore-based clients (see src/features/events/events.discovery.ts, src/features/profile/profile.client.ts, src/features/reels/reels.client.ts).
Heavy (mounted under <DappsShell />):
- Anything that needs Redux/RTK Query.
- Pages that consume Contentful rich-text, dompurify, LiveKit, full-text search.
- Existing heavy areas you can mirror:
/events/*, /blog/*, /jump/*, /social/*, /cast/*, /storage/*, /profile/*.
Layout-less (rare — fullscreen UX that bypasses navbar + footer):
- Currently
/reels/*, /download, /download_success, /invite/:referrer.
- Same rules as lightweight (no Redux, no Web3) but the
<Route> is placed BEFORE the <Route element={<Layout />}> block in src/App.tsx.
- Choose this only when the immersive UX is the whole point of the page; it sacrifices the shared header/footer.
If unsure, default to lightweight (with Layout).
Steps — lightweight route
- Create page at
src/pages/<route>/<Route>.tsx (or src/pages/<route>.tsx for a single file).
- In
src/App.tsx, add const MyPage = lazy(() => import('./pages/<route>')).
- Place
<Route> inside <Route element={<Layout />}> block, outside <Route element={<DappsShell />}>.
- Data access: co-locate a
useSyncExternalStore client under src/features/<api>/ mimicking features/events/events.discovery.ts, features/profile/, or features/reels/. Do NOT import from src/shells/*, src/services/*, or any heavy-tier feature directory: features/{cms,places,communities,cast2,storage}/*, nor the RTK Query files in features/events/ (events.client.ts, events.admin.client.ts) — only events.discovery.ts is lightweight.
- Add navbar clearance to the page's outer container (rule 13):
const Container = styled(Box)(({ theme }) => ({
paddingTop: 64,
[theme.breakpoints.up('md')]: { paddingTop: 96 }
}))
- If the page sets
<title> via Helmet + async data, follow rule 23 (call useBlogPageTracking or sibling, extend Layout.helpers.ts:isPageTrackingExempt). Otherwise the existing usePageTracking(pathname) from Layout fires correctly.
- i18n keys: namespace
page.<route>.* for meta, follow add-i18n-key skill for parity.
Steps — heavy route
- Create page at
src/pages/<area>/<Route>.tsx (e.g. under src/pages/whats-on/, src/pages/blog/, src/pages/jump/, src/pages/social/, src/pages/cast/, src/pages/storage/, src/pages/profile/).
- In
src/App.tsx, add const MyPage = lazy(() => import('./pages/<area>/<route>')).
- Place
<Route> inside <Route element={<DappsShell />}> block. If the area needs an extra Outlet wrapper (LiveKit + Notification contexts for cast, layout chrome for whats-on), add a per-area Layout component and nest the routes under it (see WhatsOnLayout, CastLayout).
- RTK Query: prefer injecting endpoints into an existing base client (
cmsClient, placesClient, socialClient, cast2Client, storageClient, subgraphClient, eventsClient, adminClient). Only add a new base client under src/services/<name>Client.ts (and register the reducer + middleware in src/shells/store.ts) when the new domain genuinely doesn't fit any existing one.
- Same navbar clearance rule (step 5 above).
- If using Helmet + async title: rule 23.
Steps — Layout-less (fullscreen) route
- Create page at
src/pages/<area>/<Page>.tsx.
- In
src/App.tsx, place the <Route> BEFORE the <Route element={<Layout />}> block (mirror reels / download / invite).
- No navbar clearance — there is no navbar to clear.
- Same data + import restrictions as the lightweight tier.
- Helmet titles still follow rule 23 if the title resolves async.
Repo sync checklist (same PR)
Every time you add, remove, or rename a route in src/App.tsx:
- README.md route table under "What lives here". Curated by hand — no codegen. Route column = every public-facing path (including legacy aliases worth advertising). Notes column = 404 catch-alls (
/area/*), legacy redirects, tier-specific quirks (fullscreen, Heavy DappsShell route).
- SEO worker
PAGES map in sites-deployer (workers/sites-worker/rollouts/routes/handlers/OpenGraphStaticPageRoute.ts). Crawlers don't run JS — Helmet titles aren't visible; the worker rewrites OG meta at the edge based on this map. Skip if non-shareable or already covered by a dedicated handler (invite, reels).
- GitHub issue templates.
.github/ISSUE_TEMPLATE/bug_report.yml has a Page / Area dropdown that explicitly says Keep options in sync with the routes defined in src/App.tsx. .github/ISSUE_TEMPLATE/feature_request.yml has a sibling Area dropdown. Missing the route here means bug reporters can't categorize their issue against the new page.
Internal-only changes (component rename, lazy-import path, comment) where no public path changes — skip all three.
A PostToolUse hook fires on every edit to src/App.tsx to surface this reminder.
Verification
npm run build && npm run preview
Then navigate the new route at http://localhost:4173/<route> and dynamic variants. Vite dev is more permissive than the prod build — a passing dev does NOT guarantee prod (rule 14).
Visually verify navbar clearance with Chrome DevTools at 375px and 1280px widths.
Boundary check
Grep before pushing:
git diff master...HEAD --name-only | xargs grep -l "from ['\"].*shells/" 2>/dev/null
Hits outside src/App.tsx and src/shells/ itself = boundary violation (rule 2).
Pitfalls
- Forgetting clearance → page content sits behind the fixed navbar.
- Adding Redux to a marketing page → ships ~580KB unnecessarily.
- Importing
useBlogPageTracking from a non-Helmet route → double page() events.
- Skipping
npm run preview → CJS-heavy deps explode at runtime in prod (rule 14).
1---2name: add-route3description: Use when adding a new route/page to the SPA. Decides Layout-less vs lightweight vs heavy (DappsShell) tier, enforces the dual-shell boundary, and adds the navbar clearance that fixed-position layout requires. Triggers on "new page", "new route", "add route", "create page", or edits to src/App.tsx.4---56# add-route78This SPA has a **dual shell with a third Layout-less group**. Routes go in one of three tiers — picking the wrong one breaks Lighthouse on the homepage, unnecessarily ships Redux to a static page, or paints content behind the fixed navbar.910## Decision1112**Lightweight** (default — no Redux, no RTK Query, no Web3, no heavy CMS / LiveKit deps):1314- Marketing pages, legal pages, sign-in landing, brand, content, ethics, help, press, discord, report, etc.15- Data via `useSyncExternalStore`-based clients (see `src/features/events/events.discovery.ts`, `src/features/profile/profile.client.ts`, `src/features/reels/reels.client.ts`).1617**Heavy** (mounted under `<DappsShell />`):1819- Anything that needs Redux/RTK Query.20- Pages that consume Contentful rich-text, dompurify, LiveKit, full-text search.21- Existing heavy areas you can mirror: `/events/*`, `/blog/*`, `/jump/*`, `/social/*`, `/cast/*`, `/storage/*`, `/profile/*`.2223**Layout-less** (rare — fullscreen UX that bypasses navbar + footer):2425- Currently `/reels/*`, `/download`, `/download_success`, `/invite/:referrer`.26- Same rules as lightweight (no Redux, no Web3) but the `<Route>` is placed BEFORE the `<Route element={<Layout />}>` block in `src/App.tsx`.27- Choose this only when the immersive UX is the whole point of the page; it sacrifices the shared header/footer.2829If unsure, default to **lightweight (with Layout)**.3031## Steps — lightweight route32331. Create page at `src/pages/<route>/<Route>.tsx` (or `src/pages/<route>.tsx` for a single file).342. In `src/App.tsx`, add `const MyPage = lazy(() => import('./pages/<route>'))`.353. Place `<Route>` **inside** `<Route element={<Layout />}>` block, **outside** `<Route element={<DappsShell />}>`.364. Data access: co-locate a `useSyncExternalStore` client under `src/features/<api>/` mimicking `features/events/events.discovery.ts`, `features/profile/`, or `features/reels/`. **Do NOT** import from `src/shells/*`, `src/services/*`, or any heavy-tier feature directory: `features/{cms,places,communities,cast2,storage}/*`, nor the RTK Query files in `features/events/` (`events.client.ts`, `events.admin.client.ts`) — only `events.discovery.ts` is lightweight.375. Add navbar clearance to the page's outer container (rule 13):38 ```ts39 const Container = styled(Box)(({ theme }) => ({40 paddingTop: 64,41 [theme.breakpoints.up('md')]: { paddingTop: 96 }42 }))43 ```446. If the page sets `<title>` via Helmet + async data, follow rule 23 (call `useBlogPageTracking` or sibling, extend `Layout.helpers.ts:isPageTrackingExempt`). Otherwise the existing `usePageTracking(pathname)` from Layout fires correctly.457. i18n keys: namespace `page.<route>.*` for meta, follow `add-i18n-key` skill for parity.4647## Steps — heavy route48491. Create page at `src/pages/<area>/<Route>.tsx` (e.g. under `src/pages/whats-on/`, `src/pages/blog/`, `src/pages/jump/`, `src/pages/social/`, `src/pages/cast/`, `src/pages/storage/`, `src/pages/profile/`).502. In `src/App.tsx`, add `const MyPage = lazy(() => import('./pages/<area>/<route>'))`.513. Place `<Route>` **inside** `<Route element={<DappsShell />}>` block. If the area needs an extra Outlet wrapper (LiveKit + Notification contexts for cast, layout chrome for whats-on), add a per-area Layout component and nest the routes under it (see `WhatsOnLayout`, `CastLayout`).524. RTK Query: prefer injecting endpoints into an existing base client (`cmsClient`, `placesClient`, `socialClient`, `cast2Client`, `storageClient`, `subgraphClient`, `eventsClient`, `adminClient`). Only add a new base client under `src/services/<name>Client.ts` (and register the reducer + middleware in `src/shells/store.ts`) when the new domain genuinely doesn't fit any existing one.535. Same navbar clearance rule (step 5 above).546. If using Helmet + async title: rule 23.5556## Steps — Layout-less (fullscreen) route57581. Create page at `src/pages/<area>/<Page>.tsx`.592. In `src/App.tsx`, place the `<Route>` BEFORE the `<Route element={<Layout />}>` block (mirror reels / download / invite).603. No navbar clearance — there is no navbar to clear.614. Same data + import restrictions as the lightweight tier.625. Helmet titles still follow rule 23 if the title resolves async.6364## Repo sync checklist (same PR)6566Every time you **add, remove, or rename** a route in `src/App.tsx`:67681. **README.md route table** under "What lives here". Curated by hand — no codegen. Route column = every public-facing path (including legacy aliases worth advertising). Notes column = 404 catch-alls (`/area/*`), legacy redirects, tier-specific quirks (`fullscreen`, `Heavy DappsShell route`).692. **SEO worker `PAGES` map** in `sites-deployer` (`workers/sites-worker/rollouts/routes/handlers/OpenGraphStaticPageRoute.ts`). Crawlers don't run JS — Helmet titles aren't visible; the worker rewrites OG meta at the edge based on this map. Skip if non-shareable or already covered by a dedicated handler (invite, reels).703. **GitHub issue templates.** `.github/ISSUE_TEMPLATE/bug_report.yml` has a `Page / Area` dropdown that explicitly says `Keep options in sync with the routes defined in src/App.tsx`. `.github/ISSUE_TEMPLATE/feature_request.yml` has a sibling `Area` dropdown. Missing the route here means bug reporters can't categorize their issue against the new page.7172Internal-only changes (component rename, lazy-import path, comment) where no public path changes — skip all three.7374A `PostToolUse` hook fires on every edit to `src/App.tsx` to surface this reminder.7576## Verification7778```bash79npm run build && npm run preview80```8182Then navigate the new route at `http://localhost:4173/<route>` and dynamic variants. **Vite dev is more permissive than the prod build** — a passing dev does NOT guarantee prod (rule 14).8384Visually verify navbar clearance with Chrome DevTools at 375px and 1280px widths.8586## Boundary check8788Grep before pushing:8990```bash91git diff master...HEAD --name-only | xargs grep -l "from ['\"].*shells/" 2>/dev/null92```9394Hits outside `src/App.tsx` and `src/shells/` itself = boundary violation (rule 2).9596## Pitfalls9798- Forgetting clearance → page content sits behind the fixed navbar.99- Adding Redux to a marketing page → ships ~580KB unnecessarily.100- Importing `useBlogPageTracking` from a non-Helmet route → double `page()` events.101- Skipping `npm run preview` → CJS-heavy deps explode at runtime in prod (rule 14).