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:


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:

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

Sources & Related

External references consulted:

Related knowledge-base articles:

Change Log

2026-08-26

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse