Skip to main content

Command Palette

Search for a command to run...

TanStack Router + Convex — Client-Side React Reference

Published
12 min read
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

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

// 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

// 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

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:

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

Directory style — folders with route.tsx:

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).

// 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.

// 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.

// 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.

// 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 PostsLayoutPostsIndex inside <Outlet />
/posts/123 PostsLayoutPostComponent inside <Outlet />

Dynamic Route

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

// 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 _.

// 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:

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.

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

Import them normally in your route files:

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

Group Directories

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

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

Navigation

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

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.

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>;
}

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

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

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

Relative Navigation

<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.

// 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:

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.

// 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:

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.

// 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

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

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.

// 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:

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:

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.

// ❌ 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)
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