
tanstack-router-migration
by redpanda-data
Redpanda Console is a developer-friendly UI for managing your Kafka/Redpanda workloads. Console gives you a simple, interactive approach for gaining visibility into your topics, masking data, managing consumer groups, and exploring real-time data with time-travel debugging.
Use Cases
Efficient Code Generation
Auto-generate boilerplate code to reduce development time.
Code Review Assistance
Analyze PR changes and suggest improvements.
Refactoring Suggestions
Suggest refactoring options to improve code quality.
SKILL.md
name: tanstack-router-migration description: "Migrate React applications from React Router to TanStack Router with file-based routing. Use when user requests: (1) Router migration, (2) TanStack Router setup, (3) File-based routing implementation, (4) React Router replacement, (5) Type-safe routing, or mentions 'migrate router', 'tanstack router', 'file-based routes'." allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Task
React Router to TanStack Router Migration
Migrate React applications from React Router to TanStack Router with file-based routing. This skill provides a structured approach for both incremental and clean migrations.
Critical Rules
ALWAYS:
- Use file-based routing with routes in
src/routes/directory - Use
fromparameter in all hooks for type safety (useParams({ from: '/path' })) - Validate search params with Zod schemas using
@tanstack/zod-adapter - Configure build tool plugin before creating routes
- Register router type for full TypeScript inference
- Use
fallback()wrapper for optional search params
NEVER:
- Edit
routeTree.gen.ts(auto-generated file) - Use React Router hooks in new code during migration
- Forget the
fromparameter (loses type safety) - Use string-only validation for search params
- Skip the build plugin configuration
Dependencies
# Core dependencies
bun add @tanstack/react-router @tanstack/zod-adapter
# Build plugin (choose one based on your bundler)
bun add -d @tanstack/router-plugin
# Optional integrations
bun add nuqs # URL state management
bun add @sentry/react # Error tracking with router integration
Migration Phases
Phase 1: Assessment
Audit existing React Router usage:
# Find all React Router imports
grep -r "from 'react-router" src/ --include="*.tsx" --include="*.ts"
grep -r 'from "react-router' src/ --include="*.tsx" --include="*.ts"
# Find hook usages
grep -r "useParams\|useSearchParams\|useNavigate\|useLocation\|useMatch" src/
Document:
- React Router version (v5 or v6)
- Number of routes
-
useParamsusage count -
useSearchParamsusage count -
useNavigateusage count - Custom Link components
- Route guards/protected routes
- Existing route structure
Phase 2: Setup
1. Configure Build Tool
See references/build-configuration.md for full configs.
Rspack/Rsbuild:
// rsbuild.config.ts
import { TanStackRouterRspack } from '@tanstack/router-plugin/rspack';
export default {
tools: {
rspack: (config) => {
config.plugins?.push(
TanStackRouterRspack({
target: 'react',
autoCodeSplitting: true,
routesDirectory: './src/routes',
generatedRouteTree: './src/routeTree.gen.ts',
quoteStyle: 'single',
semicolons: true,
})
);
// Prevent rebuild loop
config.watchOptions = { ignored: ['**/routeTree.gen.ts'] };
return config;
},
},
};
Vite:
// vite.config.ts
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
export default defineConfig({
plugins: [
TanStackRouterVite({
target: 'react',
autoCodeSplitting: true,
routesDirectory: './src/routes',
generatedRouteTree: './src/routeTree.gen.ts',
}),
react(),
],
});
2. Configure Linter
// biome.jsonc or eslint config
{
"files": {
"ignore": ["**/routeTree.gen.ts"]
},
"overrides": [
{
"include": ["**/routes/**/*"],
"linter": {
"rules": {
"style": {
"useFilenamingConvention": "off" // Allow $param.tsx naming
}
}
}
}
]
}
3. Create Routes Directory
mkdir -p src/routes
Phase 3: Router Creation
Create Router Instance:
// src/app.tsx
import { createRouter, RouterProvider } from '@tanstack/react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { routeTree } from './routeTree.gen';
import { NotFoundPage } from './components/misc/not-found-page';
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
context: {
basePath: getBasePath(),
queryClient,
},
basepath: getBasePath(),
trailingSlash: 'never',
defaultNotFoundComponent: NotFoundPage,
});
// Register router type for full TypeScript inference
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
// Extend HistoryState for typed navigation state
interface HistoryState {
// Add your custom state properties here
returnUrl?: string;
documentId?: string;
documentName?: string;
}
}
export function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
Define Router Context Type:
// src/routes/__root.tsx
import type { QueryClient } from '@tanstack/react-query';
export type RouterContext = {
basePath: string;
queryClient: QueryClient;
};
Phase 4: Route Migration
Create Root Layout:
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import type { QueryClient } from '@tanstack/react-query';
import { NuqsAdapter } from 'nuqs/adapters/tanstack-router';
export type RouterContext = {
basePath: string;
queryClient: QueryClient;
};
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
});
function RootLayout() {
return (
<>
<NuqsAdapter>
<ErrorBoundary>
<AppLayout>
<Outlet />
</AppLayout>
</ErrorBoundary>
</NuqsAdapter>
{process.env.NODE_ENV === 'development' && (
<TanStackRouterDevtools position="bottom-right" />
)}
</>
);
}
File-Based Route Structure:
src/routes/
├── __root.tsx # Root layout
├── index.tsx # / (root redirect)
├── overview/
│ └── index.tsx # /overview
├── topics/
│ ├── index.tsx # /topics
│ └── $topicName/
│ ├── index.tsx # /topics/:topicName
│ └── edit.tsx # /topics/:topicName/edit
├── security/
│ ├── index.tsx # /security (redirect)
│ ├── acls/
│ │ ├── index.tsx # /security/acls
│ │ ├── create.tsx # /security/acls/create
│ │ └── $aclName/
│ │ └── details.tsx # /security/acls/:aclName/details
See references/route-templates.md for complete templates.
Phase 5: Hook Migration
| React Router | TanStack Router |
|---|---|
useParams() | useParams({ from: '/path/$param' }) |
useSearchParams() | routeApi.useSearch() with Zod validation |
useNavigate() | useNavigate({ from: '/path' }) |
useLocation() | useLocation() (same API) |
<Link to="/path"> | <Link to="/path"> (type-safe) |
<Navigate to="/path" /> | <Navigate to="/path" /> |
See references/migration-patterns.md for detailed before/after examples.
Navigation State:
Pass typed state between routes using HistoryState:
// Navigating with state
const navigate = useNavigate();
navigate({
to: '/documents/$documentId',
params: { documentId },
state: {
returnUrl: location.pathname,
documentName: 'My Document',
},
});
// Reading state in destination component
import { useLocation } from '@tanstack/react-router';
function DocumentPage() {
const location = useLocation();
const { returnUrl, documentName } = location.state;
// Use state values...
}
useParams Migration:
// Before (React Router)
import { useParams } from 'react-router-dom';
const { id } = useParams<{ id: string }>();
// After (TanStack Router)
import { useParams } from '@tanstack/react-router';
const { id } = useParams({ from: '/items/$id' });
useSearch with Zod Validation:
// In route file
import { fallback, zodValidator } from '@tanstack/zod-adapter';
import { z } from 'zod';
const searchSchema = z.object({
tab: fallback(z.string().optional(), undefined),
page: fallback(z.number().optional(), 1),
q: fallback(z.string().optional(), undefined),
});
export const Route = createFileRoute('/items/')({
validateSearch: zodValidator(searchSchema),
component: ItemsPage,
});
// In component
import { getRouteApi, useNavigate } from '@tanstack/react-router';
const routeApi = getRouteApi('/items/');
function ItemsPage() {
const { tab, page, q } = routeApi.useSearch();
const navigate = useNavigate({ from: '/items/' });
const handleTabChange = (newTab: string) => {
navigate({ search: (prev) => ({ ...prev, tab: newTab }) });
};
}
Phase 6: Testing
Create Test Utilities:
// src/test-utils.tsx
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, type RenderOptions } from '@testing-library/react';
import { routeTree } from './routeTree.gen';
import type { RouterContext } from './routes/__root';
interface RenderWithFileRoutesOptions extends Omit<RenderOptions, 'wrapper'> {
initialLocation?: string;
routerContext?: Partial<RouterContext>;
}
export function renderWithFileRoutes(
ui: React.ReactElement | null = null,
{ initialLocation = '/', routerContext = {}, ...renderOptions }: RenderWithFileRoutesOptions = {}
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: [initialLocation] }),
context: { basePath: '', queryClient, ...routerContext },
});
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router}>{children}</RouterProvider>
</QueryClientProvider>
);
}
return {
...render(ui ?? <div />, { wrapper: Wrapper, ...renderOptions }),
router,
};
}
export async function renderRoute(location: string, options?: RenderWithFileRoutesOptions) {
const result = renderWithFileRoutes(null, { initialLocation: location, ...options });
await result.router.load();
return result;
}
Configure Vitest:
// vitest.config.integration.mts
import { tanstackRouter } from '@tanstack/router-plugin/vite';
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
routesDirectory: './src/routes',
generatedRouteTree: './src/routeTree.gen.ts',
}),
react(),
],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
});
Phase 7: Integrations
Sentry Integration:
// src/app.tsx
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: process.env.SENTRY_DSN,
integrations: [
Sentry.tanstackRouterBrowserTracingIntegration(router),
],
tracesSampleRate: 1.0,
});
nuqs Integration:
// src/routes/__root.tsx
import { NuqsAdapter } from 'nuqs/adapters/tanstack-router';
function RootLayout() {
return (
<NuqsAdapter>
<Outlet />
</NuqsAdapter>
);
}
Incremental Migration (Legacy Compatibility):
See references/incremental-migration.md for patterns to run both routers together during migration.
Quick Reference
Route File Naming
| Pattern | File | URL |
|---|---|---|
| Index route | topics/index.tsx | /topics |
| Dynamic param | topics/$topicName.tsx | /topics/:topicName |
| Nested dynamic | topics/$topicName/edit.tsx | /topics/:topicName/edit |
| Pathless layout | _layout.tsx | (no URL segment) |
| Catch-all | $.tsx | /* |
Common Zod Patterns
import { fallback, zodValidator } from '@tanstack/zod-adapter';
import { z } from 'zod';
const searchSchema = z.object({
// Optional string with undefined default
tab: fallback(z.string().optional(), undefined),
// Optional number with default value
page: fallback(z.number().optional(), 1),
// Required string
id: z.string(),
// Enum with default
sort: fallback(z.enum(['asc', 'desc']).optional(), 'asc'),
// Boolean
expanded: fallback(z.boolean().optional(), false),
});
Trailing Slash in from Parameter
The from parameter must exactly match the route path as defined:
// Index routes (files named index.tsx) include trailing slash:
useParams({ from: '/topics/$topicName/' }) // Route: topics/$topicName/index.tsx
// Non-index routes do NOT include trailing slash:
useParams({ from: '/topics/$topicName/edit' }) // Route: topics/$topicName/edit.tsx
Type-Safe Navigation
// With params
<Link to="/topics/$topicName" params={{ topicName: 'my-topic' }}>
View Topic
</Link>
// With search params
<Link to="/topics" search={{ page: 2, sort: 'desc' }}>
Page 2
</Link>
// Programmatic navigation
const navigate = useNavigate({ from: '/topics/$topicName' });
navigate({
to: '/topics/$topicName/edit',
params: { topicName },
search: { tab: 'settings' },
});
Checklist
Pre-Migration
- Dependencies installed (
@tanstack/react-router,@tanstack/router-plugin,@tanstack/zod-adapter) - Build tool plugin configured
- Linter configured to allow
$param.tsxnaming -
src/routes/directory created
Route Migration
-
__root.tsxcreated with providers and layout -
index.tsxcreated for root redirect - All routes migrated to file-based structure
- Search params validated with Zod schemas
-
staticDataadded for titles/icons
Hook Migration
- All
useParamscalls updated withfromparameter - All
useSearchParamsreplaced withrouteApi.useSearch() - All
useNavigatecalls updated withfromparameter - All
Linkcomponents verified working
Testing
-
renderWithFileRoutesutility created - Vitest configured with TanStack Router plugin
- Existing tests updated to use new utilities
Integrations
- Sentry integration configured (if used)
- nuqs adapter wrapped in root layout (if used)
Cleanup (after full migration)
- React Router dependencies removed
- Legacy route definitions deleted
- BrowserRouter wrapper removed
- RouterSync component removed
Common Pitfalls
- Missing
fromparameter - Always specifyfromin hooks for type safety - Forgetting
fallback()wrapper - Optional search params needfallback(z.string().optional(), undefined) - Trailing slash inconsistency - Configure
trailingSlash: 'never'and be consistent - Editing routeTree.gen.ts - Never edit; it's auto-generated on file changes
- Missing build plugin - Routes won't generate without the bundler plugin
- Async navigation warnings -
navigate()returns Promise; usevoid navigate()or await it - Using
<Navigate>for section redirects - UsebeforeLoadwiththrow redirect()instead to prevent navigation loops in embedded mode:beforeLoad: () => { throw redirect({ to: '/section/$tab', params: { tab: 'default' }, replace: true }); } - Trailing slash in
fromparameter for index routes - Index routes (files namedindex.tsx) require trailing slash infrom:// Index route: /topics/$topicName/index.tsx useParams({ from: '/topics/$topicName/' }) // ✅ Correct (trailing slash) useParams({ from: '/topics/$topicName' }) // ❌ Wrong - Missing HistoryState extension - Extend
HistoryStateinterface for typed navigation state (see Phase 3)
Documentation
Score
Total Score
Based on repository quality metrics
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 1000以上
1ヶ月以内に更新
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
Reviews
Reviews coming soon

