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 aRoute 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
| File | URL |
|---|---|
routes/index.tsx | / |
routes/sign-in.tsx | /sign-in |
routes/app/settings/profile.tsx | /app/settings/profile |
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
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’sloader 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
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:
Sidebar nav from route metadata
The app sidebar isn’t a hand-maintained list — it builds itself from the route tree. A route appears in the sidebar by declaringstaticData.nav:
src/routes/_authenticated/app/notes/index.tsx
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.
Conventions at a glance
| Pattern | Meaning |
|---|---|
__root.tsx | the root route — shell, head, shared providers |
index.tsx | the index (/) of its folder |
$param.tsx | dynamic segment, read via Route.useParams() |
$.tsx | catch-all splat (404) |
_layout.tsx | pathless layout route (e.g. _authenticated) |
-folder/ | private folder, ignored by the router |
routeTree.gen.ts | auto-generated, do not edit |
Next
- Server functions — backend logic behind loaders.
- Web overview — the stack and how data flows.
