TanStack Router Routing Patterns - File-Based Routes, Loaders, and Type-Safe Navigation
Status: Active
Last Updated: 2026-08-26
Category: Frontend - Architecture
Prerequisites: tanstack, website-rebuild
Time: 2 hours
Tags: tanstack-router, routing, loaders, file-based-routing, typescript
Summary
How routing works in the fogserv.cloud website (TanStack Start + TanStack Router): the flat file-based route tree in src/routes/, dot-notation nesting, path params, data loading with loaders, and fully type-safe navigation via generated route types.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ
Read and predict the URL structure from filenames in
src/routes/ - โ
Add nested layouts and dynamic
$paramroutes correctly - โ Fetch data before render with loaders instead of effect-fetching
- โ
Use typed
<Link>navigation that breaks at compile time, not runtime - โ
Keep
routeTree.gen.tsout of your diffs and conflicts
Context / Why This Matters
The website rebuild (website-rebuild) replaced Ghost with a React 19 + TanStack Start app (tanstack). Routing is where new contributors most often get confused: this project uses flat file-based routes with dot notation rather than nested folders, so admin.new-post.tsx maps to /admin/new-post. Misreading that convention leads people to create folder trees that silently shadow each other or break the generated route tree.
Implementation / Core Content
The Route Tree Today
src/routes/
โโโ __root.tsx # Root layout: <Outlet/>, devtools, global chrome
โโโ index.tsx # /
โโโ about.tsx # /about
โโโ login.tsx # /login
โโโ register.tsx # /register
โโโ profile.tsx # /profile
โโโ verify-email.tsx # /verify-email
โโโ vault.tsx # /vault (layout)
โโโ apps.vault.tsx # /apps/vault
โโโ admin.tsx # /admin (layout for admin section)
โโโ admin.new-post.tsx # /admin/new-post
โโโ kb.index.tsx # /kb/
โโโ kb_.$.tsx # /kb/<anything> โ splat catch-all
โโโ changelog.index.tsx # /changelog/
โโโ changelog_.$slug.tsx # /changelog/<slug>
Naming rules:
| Filename | URL | Notes |
|---|---|---|
index.tsx |
/ |
Directory index |
about.tsx |
/about |
Plain leaf |
admin.tsx + admin.new-post.tsx |
/admin, /admin/new-post |
Dot = path segment; admin.tsx becomes a layout wrapping children |
_auth.login.tsx (pathless) |
/login |
Leading underscore = layout without URL segment |
kb_.$.tsx |
/kb/* |
Underscore suffix = "don't nest under kb.index"; $ = splat |
changelog_.$slug.tsx |
/changelog/:slug |
$name = dynamic param |
The underscore trick matters: kb_.$.tsx (not kb.$.tsx) escapes nesting under kb.index.tsx while still matching /kb/.... Same pattern in changelog_.$slug.
Anatomy of a Route File
// src/routes/changelog_.$slug.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/changelog/$slug')({
loader: async ({ params }) => {
const post = await getPostBySlug(params.slug) // src/services/*
if (!post) throw notFound()
return { post }
},
component: ChangelogPost,
})
function ChangelogPost() {
const { post } = Route.useLoaderData() // fully typed from the loader return
return <article>{post.title}</article>
}
Loader rules:
- Loaders run before render, including on the server under TanStack Start (SSR). No loading-spinner waterfall for initial paint.
- Return plain serializable data; it's cached per-route by the router.
- Throw
notFound()orredirect()for control flow โ don't render error states manually. - For mutations, invalidate with
queryClient.invalidateQueries(TanStack Query integration per tanstack) and let the loader refetch.
Type-Safe Navigation
@tanstack/router-plugin (already in devDependencies) generates src/routeTree.gen.ts at dev/build time. That file powers compile-time-safe links:
import { Link, useNavigate } from '@tanstack/react-router'
// Typed: string literal must match a real route
<Link to="/changelog/$slug" params={{ slug: 'v0-2' }}>Changelog</Link>
// Wrong slug key โ TypeScript error, not a 404
<Link to="/changelog/$slug" params={{ slg: 'v0-2' }} /> // โ compile error
const navigate = useNavigate()
navigate({ to: '/admin/new-post' })
Never hand-edit routeTree.gen.ts; it's regenerated. Commit it (the plugin expects it present for type-checking in CI).
Nested Layouts
A parent route file renders <Outlet /> wherever child content appears:
// src/routes/admin.tsx โ layout + auth gate
export const Route = createFileRoute('/admin')({
beforeLoad: async ({ context }) => {
if (!context.auth.isAdmin) throw redirect({ to: '/login' })
},
component: () => (
<AdminShell>
<Outlet />
</AdminShell>
),
})
beforeLoad runs before the loader โ the right place for auth guards and context injection, keeping guards out of every child.
Practical Examples
Example 1: Adding a page the right way
To add /apps/status: create src/routes/apps.status.tsx, export createFileRoute('/apps/status') with a component, restart bun run dev (or let the plugin hot-regenerate), then link with <Link to="/apps/status">. Verify routeTree.gen.ts picked it up โ a stale gen file shows as "no overload matches" on the to= prop, which is the type system protecting you.
Example 2: Splat route rendering KB articles
kb_.$.tsx receives the full remainder via params._splat in its loader, which is how /kb/databases/database-selection resolves to a markdown file lookup without defining one route per article.
Example 3: Search-param-driven filtering
export const Route = createFileRoute('/kb/')({
validateSearch: z.object({ q: z.string().optional() }), // zod already in deps
loaderDeps: ({ search }) => ({ q: search.q }),
loader: ({ deps }) => searchKb(deps.q),
})
// Updates are navigations, not state:
<Route.SearchParams>
{(search) => <input value={search.q} onChange={(e) => search.replace({ q: e.target.value })} />}
</Route.SearchParams>
Filter state lives in the URL โ shareable, back-button-correct, SSR-friendly.
Common Pitfalls & Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| New route 404s in dev | Plugin didn't regenerate routeTree.gen.ts | Restart dev server; confirm file exports createFileRoute('<exact path>') |
"No overload matches to=..." |
Stale gen file or typo'd route path | Regenerate; copy the path exactly from an existing route's createFileRoute argument |
Child of kb. nests inside kb.index unexpectedly |
Missing underscore escape | Use kb_child.tsx naming (kb_.child) to escape |
| Loader data undefined on client nav | Data fetched in component instead of loader, or non-serializable return | Move fetch into loader; return JSON-safe values |
| Auth bypassed on direct URL hit | Guard only in component mount | Move check to beforeLoad so SSR and client both enforce it |
| Params missing at runtime | Link params keys don't match $name segments |
Align names; let TS catch it before deploy |
Next Steps / Ops Actions
- Wire loader failures to proper error boundaries in
__root.tsx. - Add route-level tests as described in testing-setup.
- Review styling of new routes against component-library-notes.
- CI should fail on type errors so broken links never merge โ see cicd-concepts.
Sources & Related
External references consulted:
- https://tanstack.com/router/latest/docs/framework/react/start
- https://tanstack.com/router/latest/docs/framework/react/routing/code-based-routing
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation covering file-based routing conventions, loaders, layouts, and type-safe navigation.