关于 tanstack-router-migration
面向将应用从 React Router 迁移到 TanStack Router 的 React 开发者,提供分阶段及渐进式迁移指导,涵盖基于文件的路由、类型安全 Hook、搜索参数验证和测试。
主要功能
- 01分阶段迁移工作流
- 02基于文件的路由结构
- 03类型安全的 Hook 与导航
- 04使用 Zod 验证搜索参数
- 05测试配置与迁移检查
适用场景
- 01规划 React Router 迁移
- 02逐步迁移路由
- 03更新 Hook 和搜索参数
- 04为迁移后的路由准备测试
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ redpanda-data/ console --skill tanstack-router-migration安装后在 Claude Code 中输入/tanstack-router-migration确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/build-configuration.mdincremental-migration.mdmigration-patterns.mdroute-templates.md详细介绍
从 React Router 迁移到 TanStack Router
使用基于文件的路由,将 React 应用从 React Router 迁移到 TanStack Router。本技能为渐进式迁移和全新迁移提供了结构化方案。
关键规则
务必:
- 使用基于文件的路由,并将路由放在
src/routes/目录中 - 在所有 Hook 中使用
from参数以确保类型安全(useParams({ from: '/path' })) - 使用
@tanstack/zod-adapter和 Zod 模式验证搜索参数 - 创建路由之前配置构建工具插件
- 注册路由器类型,以获得完整的 TypeScript 类型推断
- 对可选搜索参数使用
fallback()包装器
切勿:
- 编辑
routeTree.gen.ts(自动生成的文件) - 在迁移期间于新代码中使用 React Router Hook
- 忘记
from参数(会失去类型安全) - 仅使用字符串验证搜索参数
- 跳过构建插件配置
依赖项
# 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
迁移阶段
阶段 1:评估
审查现有 React Router 用法:
# 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/
记录:
- React Router 版本(v5 或 v6)
- 路由数量
-
useParams用法数量 -
useSearchParams用法数量 -
useNavigate用法数量 - 自定义 Link 组件
- 路由守卫/受保护路由
- 现有路由结构
阶段 2:设置
1. 配置构建工具
完整配置请参阅 references/build-configuration.md。
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. 配置 Linter
// biome.jsonc or eslint config
{
"files": {
"ignore": ["**/routeTree.gen.ts"]
},
"overrides": [
{
"include": ["**/routes/**/*"],
"linter": {
"rules": {
"style": {
"useFilenamingConvention": "off" // Allow $param.tsx naming
}
}
}
}
]
}
3. 创建路由目录
mkdir -p src/routes
阶段 3:创建路由器
创建路由器实例:
// 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>
);
}
定义路由器上下文类型:
// src/routes/__root.tsx
import type { QueryClient } from '@tanstack/react-query';
export type RouterContext = {
basePath: string;
queryClient: QueryClient;
};
阶段 4:迁移路由
创建根布局:
// 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" />
)}
</>
);
}
基于文件的路由结构:
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
完整模板请参阅 references/route-templates.md。
阶段 5:迁移 Hook
| React Router | TanStack Router |
|---|---|
useParams() | useParams({ from: '/path/$param' }) |
useSearchParams() | 使用 Zod 验证的 routeApi.useSearch() |
useNavigate() | useNavigate({ from: '/path' }) |
useLocation() | useLocation()(API 相同) |
<Link to="/path"> | <Link to="/path">(类型安全) |
<Navigate to="/path" /> | <Navigate to="/path" /> |
详细的迁移前后示例请参阅 references/migration-patterns.md。
导航状态:
使用 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:
// 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' });
使用 Zod 验证 useSearch:
// 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 }) });
};
}
阶段 6:测试
创建测试工具:
// 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;
}
配置 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'],
},
});
阶段 7:集成
Sentry 集成:
// src/app.tsx
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: process.env.SENTRY_DSN,
integrations: [
Sentry.tanstackRouterBrowserTracingIntegration(router),
],
tracesSampleRate: 1.0,
});
nuqs 集成:
// src/routes/__root.tsx
import { NuqsAdapter } from 'nuqs/adapters/tanstack-router';
function RootLayout() {
return (
<NuqsAdapter>
<Outlet />
</NuqsAdapter>
);
}
渐进式迁移(兼容旧版):
迁移期间同时运行两个路由器的模式请参阅 references/incremental-migration.md。
快速参考
路由文件命名
| 模式 | 文件 | URL |
|---|---|---|
| 索引路由 | topics/index.tsx | /topics |
| 动态参数 | topics/$topicName.tsx | /topics/:topicName |
| 嵌套动态路由 | topics/$topicName/edit.tsx | /topics/:topicName/edit |
| 无路径布局 | _layout.tsx | (无 URL 路径段) |
| 捕获所有路由 | $.tsx | /* |
常见 Zod 模式
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),
});
from 参数中的尾部斜杠
from 参数必须与定义的路由路径完全一致:
// 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
类型安全的导航
// 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' },
});
检查清单
迁移前
- 已安装依赖项(
@tanstack/react-router、@tanstack/router-plugin、@tanstack/zod-adapter) - 已配置构建工具插件
- 已配置 Linter,以允许
$param.tsx命名 - 已创建
src/routes/目录
路由迁移
- 创建包含 providers 和布局的
__root.tsx - 创建用于根路径重定向的
index.tsx - 将所有路由迁移到基于文件的结构
- 使用 Zod schemas 验证搜索参数
- 为标题/图标添加
staticData
Hook 迁移
- 更新所有
useParams调用,添加from参数 - 将所有
useSearchParams替换为routeApi.useSearch() - 更新所有
useNavigate调用,添加from参数 - 确认所有
Link组件均能正常工作
测试
- 创建
renderWithFileRoutes工具 - 配置 Vitest 并使用 TanStack Router 插件
- 更新现有测试以使用新工具
集成
- 配置 Sentry 集成(如有使用)
- 在根布局中包装 nuqs adapter(如有使用)
清理(完整迁移后)
- 移除 React Router 依赖
- 删除旧版路由定义
- 移除 BrowserRouter 包装器
- 移除 RouterSync 组件
常见问题
- 缺少
from参数 - 为确保类型安全,始终在 hooks 中指定from - 忘记使用
fallback()包装 - 可选搜索参数需要使用fallback(z.string().optional(), undefined) - 尾部斜杠不一致 - 配置
trailingSlash: 'never'并保持一致 - 编辑 routeTree.gen.ts - 切勿编辑;文件发生更改时会自动生成
- 缺少构建插件 - 没有 bundler 插件就不会生成路由
- 异步导航警告 -
navigate()会返回 Promise;请使用void navigate()或等待其完成 - 使用
<Navigate>进行区块重定向 - 请改用beforeLoad和throw redirect(),以避免嵌入模式下出现导航循环:beforeLoad: () => { throw redirect({ to: '/section/$tab', params: { tab: 'default' }, replace: true }); } - 索引路由的
from参数末尾缺少斜杠 - 索引路由(文件名为index.tsx)的from参数必须带有尾部斜杠:// Index route: /topics/$topicName/index.tsx useParams({ from: '/topics/$topicName/' }) // ✅ Correct (trailing slash) useParams({ from: '/topics/$topicName' }) // ❌ Wrong - 缺少 HistoryState 扩展 - 扩展
HistoryState接口,以实现类型化导航状态(参见第 3 阶段)
文档
评论
支持 Markdown