Skip to content

Routing

The app uses TanStack Router with file-based routing. Routes are defined as files in apps/app/routes/ and TanStack Router generates a typed route tree automatically.

Route File Convention

Each file in routes/ becomes a route. The file path determines the URL:

bash
apps/app/routes/
├── __root.tsx Root layout (wraps everything)
├── (auth)/
   ├── login.tsx /login
   └── signup.tsx /signup
└── (app)/
    ├── route.tsx Layout for all (app) routes
    ├── index.tsx / (dashboard)
    ├── members.tsx /members
    └── settings.tsx /settings

Parenthesized directories like (app) and (auth) are route groups – they create layout boundaries without affecting the URL. /settings is the URL, not /(app)/settings.

The generated route tree lives at apps/app/lib/routeTree.gen.ts. Don't edit it – run bun app:dev and TanStack Router regenerates it on file changes.

Route Groups

The two route groups serve different auth requirements:

GroupPurposeAuth behavior
(app)Protected app pagesRedirects to /login if unauthenticated
(auth)Login/signup pagesRedirects to / if already authenticated

Root Route

The root route (__root.tsx) creates the router context and wraps the outlet in an error boundary:

tsx
// apps/app/routes/__root.tsx
export const Route = createRootRouteWithContext<{
  queryClient: QueryClient;
}>()({
  component: Root,
});

function Root() {
  return (
    <>
      <AppErrorBoundary>
        <Outlet />
      </AppErrorBoundary>
      <Devtools />
    </>
  );
}

The queryClient in context is what makes beforeLoad guards possible – route guards can prefetch or read cached data before rendering.

Devtools

components/devtools.tsx mounts TanStack Devtools with the Router and Query panels as plugins of one shell. It renders null outside development, so nothing reaches your production bundle.

TIP

The trigger is invisible until you hover the bottom-right corner. Ctrl+Shift+X opens the panel without it.

Position, theme, panel side, and the hotkey are settings the shell persists per browser. The config prop only seeds them – once you change one from the panel's settings tab, that value wins.

Devtools sits outside AppErrorBoundary and carries its own boundary, so a render failure in either subtree can't take down the other.

Auth Guards

Protecting app routes

The (app)/route.tsx layout guard uses a cache-first strategy for instant navigation:

tsx
// apps/app/routes/(app)/route.tsx
export const Route = createFileRoute("/(app)")({
  beforeLoad: async ({ context, location }) => {
    // Check cache first – instant when data exists
    let session = getCachedSession(context.queryClient);

    // Fetch only if cache is empty (first visit or after sign-out)
    if (session === undefined) {
      session = await context.queryClient.fetchQuery(sessionQueryOptions());
    }

    // Both user and session must exist for valid auth state
    if (!isValidSession(session)) {
      throw redirect({
        to: "/login",
        search: { returnTo: location.href },
      });
    }
  },
  component: AppLayout,
});

This pattern makes subsequent navigations between protected routes instant – the session is already cached from the first load. Nothing is returned into route context: pages read the session through useSessionQuery(), so there is no second copy to go stale after a revalidation.

Redirecting authenticated users

Login and signup routes redirect authenticated users away:

tsx
// apps/app/routes/(auth)/login.tsx
export const Route = createFileRoute("/(auth)/login")({
  validateSearch: searchSchema,
  beforeLoad: async ({ context, search }) => {
    try {
      const session = await context.queryClient.fetchQuery(
        sessionQueryOptions(),
      );
      if (isValidSession(session)) {
        throw redirect({ to: search.returnTo ?? "/" });
      }
    } catch (error) {
      if (isRedirect(error)) throw error;
      // Show login form for fetch errors
    }
  },
  component: LoginPage,
});

Search Params

Validate and transform search params with Zod. The login route sanitizes returnTo to prevent open redirects:

tsx
const searchSchema = z.object({
  returnTo: z
    .string()
    .optional()
    .transform((val) => {
      const safe = getSafeRedirectUrl(val);
      return safe === "/" ? undefined : safe;
    })
    .catch(undefined),
});

Access validated search params in the component:

tsx
function LoginPage() {
  const search = Route.useSearch();
  // search.returnTo is guaranteed safe – validated at parse time
}

Use the <Link> component for type-safe navigation:

tsx
import { Link } from "@tanstack/react-router";

<Link to="/settings">Settings</Link>

// Active styling
<Link
  to="/settings"
  activeProps={{ className: "font-bold text-primary" }}
>
  Settings
</Link>

// With search params
<Link to="/login" search={{ returnTo: "/settings" }}>
  Log in
</Link>

For programmatic navigation:

tsx
const router = useRouter();
await router.navigate({ to: "/settings" });

Adding a New Route

  1. Create a route file:
tsx
// apps/app/routes/(app)/projects.tsx
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/(app)/projects")({
  component: Projects,
});

function Projects() {
  return (
    <div className="p-6">
      <h2 className="text-2xl font-bold">Projects</h2>
    </div>
  );
}
  1. The route tree regenerates automatically during bun app:dev. The new page is available at /projects and protected by the (app) layout guard.

  2. Add projects to APP_PATHS in apps/web/worker.ts. The web worker forwards only the paths listed there, so an unlisted route reaches the app on client-side navigation but 404s on direct load. The path must not collide with a marketing page in apps/web/pages/apps/app/lib/edge-routing.test.ts fails on either mistake.

  3. Add navigation in the sidebar or header as needed. See State & Data Fetching for loading data in your new route.

For more on TanStack Router, see the official docs.