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
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router';

export const Route = createRootRoute({
  ssr: false,
  head: () => ({ meta: [{ title: 'Ship' }], links: [/* fonts, favicon */] }),
  shellComponent: RootDocument,
  component: RootLayout, // renders <Outlet /> inside the shared providers
});

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
import { createFileRoute } from '@tanstack/react-router';

import Landing from '@/components/landings/dark';

export const Route = createFileRoute('/')({
  component: Landing,
});
FileURL
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
export const Route = createFileRoute('/app/ai-chat/$chatId')({
  component: ChatPage,
});

function ChatPage() {
  const { chatId } = Route.useParams();
  return <Chat key={chatId} chatId={chatId} />;
}

Catch-all: the $ splat

A bare $.tsx matches anything that no other route claims — Ship uses it for the 404 page:
src/routes/$.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router';

import { Button } from '@/components/ui/button';

export const Route = createFileRoute('/$')({
  component: NotFound,
});

function NotFound() {
  const navigate = useNavigate();
  return <Button onClick={() => navigate({ to: '/' })}>Go to homepage</Button>;
}

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
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: async () => {
    const user = await loadCurrentUser();
    if (!user) throw redirect({ to: '/sign-in' });
  },
  component: () => (
    <MainLayout>
      <Outlet />
    </MainLayout>
  ),
});
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
import { createFileRoute } from '@tanstack/react-router';

import { getStats } from '@/server/stats';

export const Route = createFileRoute('/app/stats')({
  loader: () => getStats(),
  component: Stats,
});

function Stats() {
  const stats = Route.useLoaderData();
  return <div>{stats.count}</div>;
}
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:
routes/_authenticated/app/
  settings/
    profile.tsx                 # /app/settings/profile
    security.tsx                # /app/settings/security
  -components/                  # ignored by the router
    settings/
      profile-tab.tsx
      settings-layout.tsx
Import upward from the route into the private folder as usual:
import SettingsLayout from '../-components/settings/settings-layout';
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
import { createFileRoute } from '@tanstack/react-router';
import { FileText } from 'lucide-react';

export const Route = createFileRoute('/_authenticated/app/notes/')({
  staticData: { nav: { label: 'Notes', icon: FileText, order: 10 } },
  component: NotesPage,
});
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

PatternMeaning
__root.tsxthe root route — shell, head, shared providers
index.tsxthe index (/) of its folder
$param.tsxdynamic segment, read via Route.useParams()
$.tsxcatch-all splat (404)
_layout.tsxpathless layout route (e.g. _authenticated)
-folder/private folder, ignored by the router
routeTree.gen.tsauto-generated, do not edit

Next