Skip to main content
apps/web uses TanStack Router with file-based routing. Every file under src/routes/** is a route, and the directory tree is the route table — there’s no central registry to maintain. The Vite plugin (tanstackStart) watches that folder and regenerates src/routeTree.gen.ts on every change, so the routes an agent reads are always real.

The root route

src/routes/__root.tsx is the parent of every route. It defines the document shell, the <head>, and the providers shared app-wide, then renders matched children through <Outlet />:
src/routes/__root.tsx

A route is a file

Each route file exports a Route created with createFileRoute. The path string is the route’s URL, and it’s managed for you — the Vite plugin writes it to match the file’s location, so you never hand-edit it:
src/routes/index.tsx
You don’t write the createFileRoute('/...') path by hand — the tanstackStart Vite plugin keeps it in sync with the filename. If it ever drifts, the dev server fixes it on save.

Dynamic params: $param

A $-prefixed segment is a dynamic parameter. Read it with Route.useParams():
src/routes/app/ai-chat/$chatId.tsx

Catch-all: the $ splat

A bare $.tsx matches anything that no other route claims — Ship uses it for the 404 page:
src/routes/$.tsx

Guarding routes: _authenticated

A _-prefixed segment is a pathless layout route — it wraps its children with shared logic and layout without adding a URL segment. The Auth plugin ships an _authenticated layout that gates everything beneath it: its beforeLoad checks for the current user and redirects to /sign-in if there isn’t one.
src/routes/_authenticated.tsx
Anything you drop under routes/_authenticated/app/** is signed-in-only and rendered inside MainLayout — no per-page auth checks. Plugins follow the same convention: the Notes, Admin and AI-chat plugins all merge their routes under routes/_authenticated/app/**.
_authenticated and the sign-in/up pages come from the Auth plugin. The base apps/web is just the landing page and the 404; add Auth and you get the guarded app shell.

Loaders

A route’s loader runs before its component renders — the place to fetch data so it’s ready on first paint. In the web-only shape, a loader calls a server function directly:
src/routes/app/stats.tsx
In the full-stack shape you typically fetch inside the component with the Auth plugin’s useApiQuery hook instead, so TanStack Query owns caching and refetching. See Server functions and How Ship works.

Private folders: -components/

A folder prefixed with - is ignored by the router — it’s never a route. Use it to colocate the pieces a route needs (components, hooks) right next to it without polluting the URL space:
Import upward from the route into the private folder as usual:
The app sidebar isn’t a hand-maintained list — it builds itself from the route tree. A route appears in the sidebar by declaring staticData.nav:
src/routes/_authenticated/app/notes/index.tsx
The sidebar scans the route tree and renders an item per declaring route, sorted by order. This is how a plugin feature shows up in the nav the moment it’s installed — no central nav file to edit, the same file-based philosophy as the rest of Ship.

The generated route tree

src/routeTree.gen.ts is written by the Vite plugin from your routes/** tree. It’s how the router knows every route and how TanStack Query and <Link to="..."> get full type safety on paths and params.
Never edit routeTree.gen.ts by hand — it’s regenerated on every change to routes/** and your edits will be overwritten. It’s also excluded from lint and formatting.

Conventions at a glance

Next