# TanStack Router + Convex — Client-Side React Reference

# The Stack

*   **Vite** — Build tool and dev server
    
*   **React** — UI framework
    
*   **TanStack Router** — Client-side routing with full TypeScript inference
    
*   **Convex** — Backend and real-time data layer
    

TanStack Router handles navigation, layouts, params, and search params. Convex handles data fetching, mutations, and auth. They do not overlap. Clean separation.

* * *

# Project Setup

## Install dependencies

```bash
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install @tanstack/react-router convex @convex-dev/auth
npm install -D @tanstack/router-plugin
```

## Vite config

```ts
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tanstackRouter } from "@tanstack/router-plugin/vite";

export default defineConfig({
  plugins: [
    // MUST come before react()
    tanstackRouter({
      target: "react",
      autoCodeSplitting: true,
    }),
    react(),
  ],
});
```

The `tanstackRouter` plugin watches your `src/routes/` folder. It auto-generates a route tree file (`routeTree.gen.ts`). It also handles code splitting automatically — each route's component gets its own chunk.

## Entry point

```tsx
// src/main.tsx
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { ConvexAuthProvider } from "@convex-dev/auth/react";
import { ConvexReactClient } from "convex/react";
import { routeTree } from "./routeTree.gen";

// Create router
const router = createRouter({ routeTree });

// Register router types globally
declare module "@tanstack/react-router" {
  interface Register {
    router: typeof router;
  }
}

// Create Convex client
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);

// Render
const root = document.getElementById("root")!;
ReactDOM.createRoot(root).render(
  <StrictMode>
    <ConvexAuthProvider client={convex}>
      <RouterProvider router={router} />
    </ConvexAuthProvider>
  </StrictMode>,
);
```

Key points:

*   `routeTree` is auto-generated. Never edit it manually.
    
*   The `declare module` block registers your router's types globally. This gives you autocomplete on every `<Link>` and `useNavigate` call without importing the router.
    
*   Convex wraps the router. This means every route component can use Convex hooks.
    

* * *

# File-Based Routing

## How it works

You create files in `src/routes/`. The plugin reads them and generates a route tree. The file name and location determine the URL path. No manual route registration needed.

## File structure = URL structure

```plaintext
src/routes/
├── __root.tsx            →  Root layout (always renders)
├── index.tsx             →  /
├── about.tsx             →  /about
├── posts.tsx             →  /posts (layout)
├── posts.index.tsx       →  /posts (default content)
├── posts.$postId.tsx     →  /posts/:postId
```

## Flat vs directory style

Both produce identical route trees. Choose whichever you prefer.

**Flat style** — dots separate segments:

```plaintext
routes/
├── posts.tsx
├── posts.index.tsx
├── posts.$postId.tsx
```

**Directory style** — folders with `route.tsx`:

```plaintext
routes/
├── posts/
│   ├── route.tsx       ← layout (has <Outlet />)
│   ├── index.tsx       ← default child content
│   ├── $postId.tsx     ← dynamic child
```

You can mix both styles in the same project.

## Special file naming

| Pattern | Meaning |
| --- | --- |
| `$param` | Dynamic segment — captures URL part as a param |
| `_name` | Pathless layout — wraps children without adding to URL |
| `name_` | Non-nested — breaks out of parent nesting |
| `-name` | Excluded — ignored by router, for colocated helpers |
| `(name)/` | Group directory — organizational only, no effect on URLs |

* * *

# Route Types Explained

## Root Route

Always renders. Wraps everything. Put your app shell here (nav, footer, etc).

```tsx
// src/routes/__root.tsx
import { createRootRoute, Outlet } from "@tanstack/react-router";

export const Route = createRootRoute({
  component: RootLayout,
});

function RootLayout() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/posts">Posts</Link>
        <Link to="/about">About</Link>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}
```

## Basic Route

Matches a path exactly. Renders a component.

```tsx
// src/routes/about.tsx
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/about")({
  component: AboutPage,
});

function AboutPage() {
  return <div>About us</div>;
}
```

## Layout Route

Wraps child routes. Uses `<Outlet />` to render the active child.

```tsx
// src/routes/posts.tsx (or src/routes/posts/route.tsx)
import { createFileRoute, Outlet } from "@tanstack/react-router";

export const Route = createFileRoute("/posts")({
  component: PostsLayout,
});

function PostsLayout() {
  return (
    <div>
      <h1>Posts</h1>
      <Outlet /> {/* Child routes render here */}
    </div>
  );
}
```

## Index Route

Default content when the parent matches exactly and no child matches.

```tsx
// src/routes/posts.index.tsx
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/posts/")({
  component: PostsIndex,
});

function PostsIndex() {
  return <div>Select a post from the list</div>;
}
```

How layout + index + dynamic work together:

| URL | What renders |
| --- | --- |
| `/posts` | `PostsLayout` → `PostsIndex` inside `<Outlet />` |
| `/posts/123` | `PostsLayout` → `PostComponent` inside `<Outlet />` |

## Dynamic Route

Captures a URL segment as a param. Use `$` prefix.

```tsx
// src/routes/posts.$postId.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";

export const Route = createFileRoute("/posts/$postId")({
  component: PostPage,
});

function PostPage() {
  const { postId } = Route.useParams(); // ← typed as { postId: string }
  const post = useQuery(api.posts.getById, { id: postId });

  if (!post) return <div>Loading...</div>;
  return <div>{post.title}</div>;
}
```

## Pathless Layout Route

Wraps children with shared UI or logic but adds nothing to the URL. Prefixed with `_`.

```tsx
// src/routes/_authenticated.tsx
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { useConvexAuth } from "convex/react";

export const Route = createFileRoute("/_authenticated")({
  component: AuthLayout,
});

function AuthLayout() {
  const { isAuthenticated, isLoading } = useConvexAuth();
  if (isLoading) return <div>Loading...</div>;
  if (!isAuthenticated) return <LoginForm />;
  return <Outlet />;
}
```

Child routes under this layout:

```plaintext
routes/
├── _authenticated.tsx              ← auth check
├── _authenticated.dashboard.tsx    ← /dashboard (protected)
├── _authenticated.settings.tsx     ← /settings (protected)
├── login.tsx                       ← /login (public)
```

The `_authenticated` prefix does not appear in the URL. Users see `/dashboard` not `/_authenticated/dashboard`.

## Colocating Helper Files

Prefix with `-` to exclude from routing. Great for keeping related code together.

```plaintext
routes/
├── posts.tsx
├── -posts-helpers.ts        ← ignored by router
├── -components/             ← ignored by router
│   ├── PostCard.tsx
│   ├── PostList.tsx
```

Import them normally in your route files:

```tsx
import { PostCard } from "./-components/PostCard";
```

## Group Directories

Use `()` for organization. No effect on URLs or route tree.

```plaintext
routes/
├── (marketing)/
│   ├── about.tsx          → /about
│   ├── pricing.tsx        → /pricing
├── (app)/
│   ├── dashboard.tsx      → /dashboard
│   ├── settings.tsx       → /settings
```

* * *

# Navigation

## Link Component (preferred)

Renders a real `<a>` tag. Supports cmd/ctrl+click, accessibility, active state styling.

```tsx
import { Link } from '@tanstack/react-router'

// Static link
<Link to="/about">About</Link>

// Dynamic link with params
<Link to="/posts/$postId" params={{ postId: '123' }}>
  View Post
</Link>

// With search params
<Link to="/posts" search={{ page: 2, sort: 'newest' }}>
  Page 2
</Link>

// Update search params from current route
<Link to="." search={(prev) => ({ ...prev, page: prev.page + 1 })}>
  Next Page
</Link>

// Active link styling
<Link
  to="/posts"
  activeProps={{ style: { fontWeight: 'bold' } }}
  activeOptions={{ exact: true }}
>
  Posts
</Link>

// Preload on hover
<Link to="/posts/$postId" params={{ postId: '123' }} preload="intent">
  View Post
</Link>
```

## useNavigate (for side effects)

Use after a mutation succeeds, form submission, or any programmatic navigation.

```tsx
import { useNavigate } from "@tanstack/react-router";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";

function CreatePostForm() {
  const navigate = useNavigate();
  const createPost = useMutation(api.posts.create);

  const handleSubmit = async (data) => {
    const postId = await createPost(data);
    navigate({ to: "/posts/$postId", params: { postId } });
  };

  return <form onSubmit={handleSubmit}>...</form>;
}
```

## Navigate Component (instant redirect)

Redirects as soon as the component mounts. No user interaction needed.

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

function SomeComponent() {
  if (shouldRedirect) {
    return <Navigate to="/login" />;
  }
  return <div>Content</div>;
}
```

## Relative Navigation

```tsx
<Link to=".">Reload current route</Link>
<Link to="..">Go up one level</Link>
```

* * *

# Params and Search Params

## Path Params

Defined by `$` in the file name. Accessed via `Route.useParams()`. Always strings.

```tsx
// File: routes/users.$userId.tsx → URL: /users/abc123
function UserPage() {
  const { userId } = Route.useParams();
  // userId is typed as string
  const user = useQuery(api.users.getById, { id: userId });
}
```

Multiple dynamic segments work too:

```plaintext
routes/posts.$postId.comments.$commentId.tsx
→ /posts/123/comments/456
→ useParams() returns { postId: '123', commentId: '456' }
```

## Search Params

Defined via `validateSearch` on the route. Accessed via `Route.useSearch()`. Fully typed.

```tsx
// src/routes/posts.index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";

const searchSchema = z.object({
  page: z.number().int().nonnegative().catch(1),
  sort: z.enum(["newest", "oldest"]).catch("newest"),
  query: z.string().optional(),
});

export const Route = createFileRoute("/posts/")({
  validateSearch: searchSchema,
  component: PostsPage,
});

function PostsPage() {
  const { page, sort, query } = Route.useSearch();
  // page: number, sort: 'newest' | 'oldest', query: string | undefined
  // All fully typed from the Zod schema

  const posts = useQuery(api.posts.list, { page, sort, query });

  return <div>...</div>;
}
```

Whatever you return from `validateSearch` becomes the type of `Route.useSearch()`. TypeScript infers it automatically. When you use `<Link>` to navigate to this route, TypeScript also knows what search params are expected.

## Deep Access with getRouteApi

When you are deep in the component tree and cannot import the Route object directly:

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

const routeApi = getRouteApi("/posts/$postId");

function DeepChildComponent() {
  const { postId } = routeApi.useParams();
  const search = routeApi.useSearch();
}
```

* * *

# Authentication with Convex Auth

## Auth layout pattern

Use a pathless layout route to protect routes. Check auth in the component with Convex hooks.

```tsx
// src/routes/_authenticated.tsx
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { useConvexAuth } from "convex/react";

export const Route = createFileRoute("/_authenticated")({
  component: AuthGuard,
});

function AuthGuard() {
  const { isAuthenticated, isLoading } = useConvexAuth();

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (!isAuthenticated) {
    return <LoginPage />;
  }

  return <Outlet />;
}
```

## Sign in / sign out

```tsx
import { useAuthActions } from "@convex-dev/auth/react";

function LoginPage() {
  const { signIn } = useAuthActions();

  return <button onClick={() => signIn("github")}>Sign in with GitHub</button>;
}

function UserMenu() {
  const { signOut } = useAuthActions();

  return <button onClick={() => signOut()}>Sign out</button>;
}
```

## Protected route file structure

```plaintext
routes/
├── __root.tsx                        ← app shell
├── index.tsx                         ← / (public)
├── login.tsx                         ← /login (public)
├── _authenticated.tsx                ← auth guard (pathless)
├── _authenticated.dashboard.tsx      ← /dashboard (protected)
├── _authenticated.settings.tsx       ← /settings (protected)
├── _authenticated.posts.tsx          ← /posts layout (protected)
├── _authenticated.posts.index.tsx    ← /posts (protected)
├── _authenticated.posts.$postId.tsx  ← /posts/:id (protected)
```

All routes prefixed with `_authenticated` are protected. The auth check runs once in the layout. If the user is not authenticated, child routes never render.

* * *

# Data Fetching with Convex

## The pattern

TanStack Router handles routing. Convex handles data. No loaders needed.

```tsx
// In your route component, just use Convex hooks
import { useQuery, useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";

function PostPage() {
  const { postId } = Route.useParams();

  // Data streams in reactively. Auto-updates when data changes.
  const post = useQuery(api.posts.getById, { id: postId });

  // Mutations return promises. Chain with navigation.
  const deletePost = useMutation(api.posts.remove);

  const navigate = useNavigate();

  const handleDelete = async () => {
    await deletePost({ id: postId });
    navigate({ to: "/posts" });
  };

  if (!post) return <div>Loading...</div>;

  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <button onClick={handleDelete}>Delete</button>
    </div>
  );
}
```

## Passing params to Convex queries

Path params and search params flow naturally into Convex queries:

```tsx
function PostsPage() {
  // From URL path
  const { categoryId } = Route.useParams();

  // From URL search params
  const { page, sort } = Route.useSearch();

  // Pass them straight to Convex
  const posts = useQuery(api.posts.list, {
    categoryId,
    page,
    sort,
  });
}
```

## Conditional queries

Skip a Convex query by passing `"skip"` as the second argument:

```tsx
function PostPage() {
  const { postId } = Route.useParams();

  // Only fetch when postId exists
  const post = useQuery(api.posts.getById, postId ? { id: postId } : "skip");
}
```

* * *

# Automatic Code Splitting

Enabled by `autoCodeSplitting: true` in the Vite config. No extra work needed.

What it does:

*   Each route's `component` gets its own chunk
    
*   Each route's `errorComponent` gets its own chunk
    
*   Each route's `notFoundComponent` gets its own chunk
    
*   Chunks load on demand as users navigate
    

One rule: **Do not export route component functions.** If you export them, they end up in the main bundle and will not be code split.

```tsx
// ❌ Bad — prevents code splitting
export function PostsPage() { ... }

// ✅ Good — stays in its own chunk
function PostsPage() { ... }
```

* * *

# Quick Reference

## Route hooks

| Hook | Returns | Use for |
| --- | --- | --- |
| `Route.useParams()` | Path params object | Dynamic segments like `$postId` |
| `Route.useSearch()` | Search params object | URL query params like `?page=2` |
| `Route.useNavigate()` | Navigate function | Programmatic navigation from this route |
| `Route.useLoaderData()` | Loader data | Only if using route loaders (not needed with Convex) |

## Navigation cheat sheet

| Need | Use |
| --- | --- |
| Clickable link | `<Link to="/path">` |
| Navigate after side effect | `useNavigate()` then `navigate({ to: '/path' })` |
| Instant redirect on mount | `<Navigate to="/path" />` |
| Navigate from outside React | `router.navigate({ to: '/path' })` |

## File naming cheat sheet

| File | URL | Purpose |
| --- | --- | --- |
| `__root.tsx` | — | App shell, always renders |
| `index.tsx` | `/` | Home page |
| `about.tsx` | `/about` | Static page |
| `posts.tsx` | `/posts` | Layout with `<Outlet />` |
| `posts.index.tsx` | `/posts` | Default child of layout |
| `posts.$postId.tsx` | `/posts/:id` | Dynamic child |
| `_auth.tsx` | — | Pathless layout (auth guard) |
| `_auth.dashboard.tsx` | `/dashboard` | Protected route |
| `posts_.$postId.edit.tsx` | `/posts/:id/edit` | Non-nested (own layout) |
| `-helpers.ts` | — | Excluded, for colocation |
| `(group)/file.tsx` | `/file` | Grouped, organizational only |
