In short
- Folders are the routes, and the routes are the only list. The docs nav, breadcrumbs, tab titles and section pages are all built from the route tree and each route's
staticData. Nothing else lists pages, so nothing can fall out of step. - An experiment is mostly docs. A prototype or feature lives in one folder. Its docs sit at its own URL (
/prototypes/baseline) inside a pathless_docs/layout; the thing itself runs under the reserved segmentapp/(/prototypes/baseline/app), with its own chrome and nothing of the docs around it. - Experiments are sealed off from each other. Each folder may import only itself and the shared platform. Reusing another experiment's code means copying it. A commit with a cross-import is refused.
- Mistakes fail early. Missing titles, a breadcrumb step with no page, or
appin the wrong place are type errors; a cross-import is a Fallow error, both before every commit.
The shape of an experiment
src/routes/prototypes/baseline/
route.tsx names the experiment ("Baseline")
_docs/route.tsx the docs shell: header, docs nav, page (adds nothing to the URL)
_docs/index.tsx /prototypes/baseline the docs start page ("Overview" in the nav)
_docs/$slug.tsx /prototypes/baseline/status the -docs/*.md files
_docs/decisions.tsx /prototypes/baseline/decisions its decisions, from -lib/decisions.ts
_docs/rules/ /prototypes/baseline/rules/… a real group: route.tsx, index.tsx, pages
_docs/components/ /prototypes/baseline/components/…
app/route.tsx /prototypes/baseline/app the running prototype: session, its own chrome
app/_public/… /prototypes/baseline/app/search
app/admin/… /prototypes/baseline/app/admin/…
-lib/ code: components, rules, decisions, server functions
-docs/ markdown for people: status, notesFeatures have the same shape: _docs/ for docs, app/ for the running feature (the image grid demo, the page builder's admin and site, the order board, the decks, sample receipts).
TanStack's file names
TanStack Router turns files into routes by their names (file naming conventions).
| Name | Means | Why we use it | Example here |
|---|---|---|---|
__root.tsx | The root route around every page | The HTML shell, and the tab title and description built from the trail | src/routes/__root.tsx |
route.tsx | The route for its folder's own URL, usually a layout around the pages inside | It names a section: its title is the breadcrumb and nav group label | prototypes/baseline/route.tsx |
index.tsx | The page at the folder's URL exactly | Every section has one, so every breadcrumb opens a page; the nav calls it "Overview" | _docs/rules/index.tsx |
$name | A URL parameter | Markdown docs, rule charts, component pages, orders | _docs/rules/$chartId.tsx |
_name | A pathless layout: wraps its pages in a component without adding to the URL | _docs puts the docs shell around docs pages; _public and _checkout give app pages their chrome | /features/receipt/variations, not /features/receipt/_docs/variations |
-name | Ignored by the router | Code (-lib) and markdown (-docs) sit next to the routes that use them | prototypes/baseline/-lib/ |
a.b.tsx | Flat nesting: b under a | Only for a page plus its PDF, kept side by side | app/receipt.$orderId.pdf.ts |
(name) | A route group: folder without a URL segment or layout | Not used. Parentheses are risky in globs (see below) | — |
Our conventions, and why
One folder per experiment. Experiments may test ideas that contradict each other, so each has its own code, server state and docs. Nothing has to stay compatible across them.
Docs by default, the app under app/. Most of an experiment's pages are docs, so docs get the experiment's own URL. The running thing gets one reserved segment, app. That gives every kind of file one pattern that tools can select:
| Files | Pattern |
|---|---|
| Docs pages | src/routes/docs/**, src/routes/*/*/_docs/** |
| Running apps | src/routes/*/*/app/** |
| Experiment code | src/routes/*/*/-lib/** |
| Experiment markdown | src/routes/*/*/-docs/** |
Rules for Claude (.claude/rules/*.md with paths:), hooks (if: "Edit(**/_docs/**)"), lint overrides and doc lists all select files by patterns like these, so a stable naming scheme beats a list of exceptions. _docs and app are plain characters in every glob engine, regex and shell; parentheses aren't.
_docs is pathless, app is not. The docs shell (header, nav) wraps docs pages but shouldn't add /docs to their URLs, so it's a pathless layout. The app needs a real URL segment, because the docs start page already owns the experiment's URL.
app is reserved, and apps are on their own. app is used for nothing else. App pages never show in the docs nav or breadcrumbs, and their tab titles name only the app ("Search · NTB Bilder og video"): a prototype in a user test shows only what its users would see. An app may build its own breadcrumb from its own routes. The facilitator's controls are behind the flask icon (prototype menu).
Every breadcrumb opens a page. Every URL prefix outside an app is a page: a section has route.tsx (its name) and index.tsx (its page). Section pages that only list their pages use SectionIndex, which reads the same route tree as the nav.
Nesting only for real groups. Markdown docs sit at the experiment's root (/prototypes/baseline/decisions). A folder appears only when pages belong together (rules, components, foundations).
Inside apps, URLs copy the real site. Prefixes inside app/ (asset, confirmation, admin/product-config) don't need pages, and apps may redirect to their entry page (app/admin to the first admin page, app/site to /site/nb).
Silos: what an experiment may use
Experiments often test ideas that contradict each other, so each one is sealed off.
| Folder | What's in it | Who may import it |
|---|---|---|
src/routes/<kind>/<id>/ | One experiment: its docs, app, code and markdown | Only itself |
src/platform/ | Neutral building blocks: UI components, formatters, the media fixtures, the docs shell and nav, the chart engine, the design tokens | Every experiment |
src/reference/ | im-web today, team proposals, and the working model (the business rules experiments start from) | Only the Docs pages; experiments copy what they need |
src/routes/docs/, the root routes | Docs and the lists of experiments | Nobody |
- Copy, don't import.
bun scripts/copy-from.ts <source> <kind>/<target> <file>copies a file and everything it needs into-lib/from-<source>/and notes the source commit incopied-from.json. Each experiment then owns its version and can change it freely. - The working model is the source. It lives in
src/reference/working-model/. Baseline has its own copy in-lib/rules/; improving the shared model means changing the reference, then copying it again. - Checked by Fallow (
.fallowrc.json). Every folder undersrc/routes/prototypesandsrc/routes/featuresis its own zone automatically. The check runs after each edit Claude makes and in the commit hook, so a cross-import never reaches the repo. - Before every commit (
.githooks/pre-commit, turned on bybun install): formatting, lint with the type checker, and Fallow's check for cross-imports and new unused code. Existing unused code is recorded infallow.baseline.json.bun run checkalso reports duplication and complexity, without stopping anything.
One source: staticData
Every route says what it's called. src/platform/page-meta.ts makes title and description required on every route:
staticData: { title: 'Credits lifecycle', description: 'How credits are bought, used up and expire.' }title: nullfor routes that aren't a step: pathless layouts, redirects, PDFs.orderplaces a page among its siblings in the nav. The index page always comes first.entrieslists the params of a$paramroute, so the nav can link each one.docMeta(docs)does this for markdown docs.- Titles can be functions of the URL's params (a doc's heading, a chart's title), so the nav can label a link without loading the page.
From that one source:
| What | Built by |
|---|---|
| Tab title ("Credits lifecycle · Rules · Baseline") and meta description | head() in __root.tsx, from the matched routes |
| Breadcrumbs, h1 and lead on docs pages | PageHeader in DocsPage, from the matched routes |
| The docs nav | docs-nav-tree.ts, walking the router's route tree |
| Section pages | SectionIndex, from the same tree |
What catches mistakes
| Mistake | Caught by |
|---|---|
A route without staticData (title and description) | The type checker: StaticDataRouteOption requires them |
| A URL prefix outside an app that isn't a section with an index page | The type checker: EveryPrefixIsAPage in src/platform/route-guard.ts names the paths |
app used anywhere but /<kind>/<id>/app | The type checker: AppOnlyUnderAnExperiment, same file |
| A link to a route that doesn't exist | The type checker: links are typed against the route tree |
| An experiment importing another experiment or the reference | Fallow (.fallowrc.json), after each edit and before every commit |
| An experiment's instructions for Claude out of step with its decisions | bun test: generated-rules.test.ts (run bun scripts/generate-rules.ts) |
A $param docs route without entries, a page with pages under it but no route.tsx | A warning in the browser console in dev ([docs nav]) |
Lint, the type checker, the tests and Fallow run before every commit, on what is staged (.githooks/pre-commit), and bun run build runs lint first, so none of these can reach the repo or a build.