TanStack Router + Convex — Client-Side React Reference

Just a guy who loves to write code and watch anime.
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:
routeTreeis auto-generated. Never edit it manually.The
declare moduleblock registers your router's types globally. This gives you autocomplete on every<Link>anduseNavigatecall 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 |
PostsLayout → PostsIndex inside <Outlet /> |
/posts/123 |
PostsLayout → PostComponent 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
Link Component (preferred)
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>;
}
Navigate Component (instant redirect)
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
componentgets its own chunkEach route's
errorComponentgets its own chunkEach route's
notFoundComponentgets its own chunkChunks 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) |
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 |






