TanStack Start 中使用 Next.js 风格路由

为什么需要 Next.js 风格路由?

如果你的团队习惯了 Next.js App Router 的 app/ 目录、page.tsxlayout.tsx 命名约定,在迁移到 TanStack Start 时可能会感到不适应。TanStack Start 默认使用 routes/ 目录、index.tsxroute.tsx 的命名方式。

好消息是,TanStack Router 提供了灵活的配置选项,让你可以自定义文件命名约定,使其与 Next.js 保持一致!

默认对比

TanStack Start 默认风格

routes/
├── __root.tsx        # 根布局
├── index.tsx         # / 路由
├── about.tsx         # /about 路由
└── blog/
    ├── route.tsx     # /blog 布局
    ├── index.tsx     # /blog 页面
    └── $slug.tsx     # /blog/:slug 动态路由

Next.js App Router 风格

app/
├── layout.tsx        # 根布局
├── page.tsx          # / 路由
├── about/
│   └── page.tsx      # /about 路由
└── blog/
    ├── layout.tsx    # /blog 布局
    ├── page.tsx      # /blog 页面
    └── [slug]/
        └── page.tsx  # /blog/:slug 动态路由

核心配置:让 TanStack Start 使用 Next.js 风格

关键在于 vite.config.ts 中的 TanStack Start 插件配置:

// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'

export default defineConfig({
  plugins: [
    tanstackStart({
      router: {
        routesDirectory: './app',      // ✅ 使用 app 目录代替 routes
        indexToken: 'page',             // ✅ 使用 page.tsx 代替 index.tsx
        routeToken: 'layout',           // ✅ 使用 layout.tsx 代替 route.tsx
        routeFileIgnorePattern:         // ✅ 忽略非路由文件
          '^(?=.*\\.)((?!.*(?:^|\\/)(?:page|layout|__root)\\.(?:t|j)sx?$).)*$',
      },
    }),
  ],
})

配置项详解

配置项 默认值 Next.js 风格值 说明
routesDirectory './routes' './app' 路由文件所在目录
indexToken 'index' 'page' 页面文件的命名 token
routeToken 'route' 'layout' 布局文件的命名 token
routeFileIgnorePattern (默认) (自定义正则) 忽略非路由文件的模式

实际应用示例

配置完成后,你的项目结构就可以像 Next.js 一样组织了:

1. 根布局(app/__root.tsx

根布局保持使用 __root.tsx 命名(这是 TanStack Router 的特殊约定):

// app/__root.tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'

export const Route = createRootRoute({
  head: () => ({
    meta: [
      { charSet: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { title: 'TanStack Start App' },
    ],
  }),
  shellComponent: RootDocument,
})

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <head>
        <HeadContent />
      </head>
      <body>
        {children}
        <Scripts />
      </body>
    </html>
  )
}

2. 首页布局(app/layout.tsx

现在可以使用 layout.tsx 代替原来的 route.tsx

// app/layout.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
  component: Layout,
})

function Layout() {
  return (
    <div className="min-h-screen bg-gradient-to-b from-slate-900 to-slate-800">
      <Outlet />
    </div>
  )
}

3. 首页(app/page.tsx

使用 page.tsx 代替原来的 index.tsx

// app/page.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
  component: HomePage,
})

function HomePage() {
  return (
    <div>
      <h1>欢迎来到 TanStack Start</h1>
      <p>使用 Next.js 风格的路由约定</p>
    </div>
  )
}

4. 嵌套路由示例

对于嵌套路由,文件结构如下:

app/
├── __root.tsx
├── layout.tsx          # / 布局
├── page.tsx            # / 页面
└── blog/
    ├── layout.tsx      # /blog 布局
    ├── page.tsx        # /blog 页面
    └── $slug/
        └── page.tsx    # /blog/:slug 页面

博客布局

// app/blog/layout.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/blog')({
  component: BlogLayout,
})

function BlogLayout() {
  return (
    <div className="blog-container">
      <nav className="blog-nav">
        <h2>博客导航</h2>
      </nav>
      <main>
        <Outlet /> {/* 渲染子路由 */}
      </main>
    </div>
  )
}

博客列表页

// app/blog/page.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/blog/')({
  component: BlogIndex,
})

function BlogIndex() {
  return (
    <div>
      <h1>博客文章列表</h1>
    </div>
  )
}

动态路由(文章详情)

// app/blog/$slug/page.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/blog/$slug')({
  component: BlogPost,
})

function BlogPost() {
  const { slug } = Route.useParams()

  return (
    <article>
      <h1>文章: {slug}</h1>
      <div>文章内容...</div>
    </article>
  )
}

关键差异:动态路由命名

需要注意的是,虽然我们可以使用 Next.js 风格的 page.tsxlayout.tsx,但动态路由的命名仍然保持 TanStack Router 的约定:

特性 Next.js TanStack Start (Next.js 风格配置后)
页面文件 page.tsx page.tsx
布局文件 layout.tsx layout.tsx
动态参数 [slug]/page.tsx $slug/page.tsx ⚠️ 仍使用 $
Catch-all [...slug]/page.tsx $.tsx ⚠️ 语法不同
可选 Catch-all [[...slug]]/page.tsx (需自定义实现)

示例目录结构对比

# Next.js
app/
└── blog/
    └── [slug]/
        └── page.tsx

# TanStack Start (Next.js 风格)
app/
└── blog/
    └── $slug/
        └── page.tsx

完整的 Vite 配置示例

// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import viteTsConfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [
    viteTsConfigPaths({
      projects: ['./tsconfig.json'],
    }),
    tanstackStart({
      router: {
        // 使用 app 目录
        routesDirectory: './app',

        // 使用 page.tsx 作为页面文件
        indexToken: 'page',

        // 使用 layout.tsx 作为布局文件
        routeToken: 'layout',

        // 忽略其他文件,只识别 page.tsx、layout.tsx 和 __root.tsx
        routeFileIgnorePattern:
          '^(?=.*\\.)((?!.*(?:^|\\/)(?:page|layout|__root)\\.(?:t|j)sx?$).)*$',
      },
    }),
    viteReact(),
  ],
})

路由文件忽略模式详解

routeFileIgnorePattern 是一个正则表达式,用于指定哪些文件应该被忽略(不作为路由文件)。

上面的正则表达式的含义是:

  • 忽略所有包含文件扩展名不是 page.tsx/ts/jsx/jslayout.tsx/ts/jsx/js__root.tsx/ts/jsx/js 的文件

这样,你可以在 app/ 目录中放置其他辅助文件(如组件、工具函数等),它们不会被误认为是路由文件:

app/
├── __root.tsx
├── page.tsx
├── components/
│   └── Header.tsx       # ✅ 会被忽略
├── utils/
│   └── helpers.ts       # ✅ 会被忽略
└── blog/
    ├── layout.tsx
    ├── page.tsx
    └── BlogCard.tsx     # ✅ 会被忽略

优势与权衡

✅ 优势

  1. 降低迁移成本:团队从 Next.js 迁移时,保持熟悉的文件结构
  2. 提高可读性page.tsxlayout.tsx 语义更明确
  3. 团队一致性:如果团队同时维护 Next.js 和 TanStack Start 项目,统一的命名减少认知负担
  4. 灵活配置:TanStack Router 允许你自定义任何命名约定

⚠️ 权衡

  1. 不是完全兼容:动态路由仍使用 $slug 而不是 [slug]
  2. 学习曲线:需要理解配置项的含义
  3. 社区示例:大多数 TanStack Start 示例和文档使用默认约定,可能需要手动适配

何时使用 Next.js 风格路由?

推荐使用的场景

  • 团队正在从 Next.js 迁移到 TanStack Start
  • 团队成员更熟悉 Next.js 的命名约定
  • 同时维护 Next.js 和 TanStack Start 项目,希望保持一致性

不推荐使用的场景

  • 全新项目且团队没有 Next.js 背景
  • 更喜欢 TanStack Router 的默认约定(index.tsxroute.tsx 更简洁)
  • 想要与 TanStack 社区示例保持一致

示例项目

一个完整的示例项目结构:

my-app/
├── app/
│   ├── __root.tsx
│   ├── layout.tsx
│   ├── page.tsx
│   ├── about/
│   │   └── page.tsx
│   ├── blog/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── $slug/
│   │       └── page.tsx
│   └── dashboard/
│       ├── layout.tsx
│       ├── page.tsx
│       └── settings/
│           └── page.tsx
├── src/
│   └── components/
│       └── Header.tsx
├── vite.config.ts
└── package.json

迁移步骤

如果你要从默认的 TanStack Start 项目迁移到 Next.js 风格:

步骤 1: 修改 Vite 配置

// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'

export default defineConfig({
  plugins: [
    tanstackStart({
      router: {
        routesDirectory: './app',
        indexToken: 'page',
        routeToken: 'layout',
        routeFileIgnorePattern:
          '^(?=.*\\.)((?!.*(?:^|\\/)(?:page|layout|__root)\\.(?:t|j)sx?$).)*$',
      },
    }),
  ],
})

步骤 2: 重命名目录

# 将 routes 目录重命名为 app
mv routes app

步骤 3: 重命名文件

# 在 app 目录中
# index.tsx → page.tsx
# route.tsx → layout.tsx

# 示例
mv app/index.tsx app/page.tsx
mv app/blog/route.tsx app/blog/layout.tsx
mv app/blog/index.tsx app/blog/page.tsx

步骤 4: 重启开发服务器

pnpm dev

TanStack Router 会自动重新生成路由类型,现在你的项目就使用 Next.js 风格的路由了!

常见问题

Q1: 为什么 __root.tsx 不能改成 layout.tsx

A: __root.tsx 是 TanStack Router 的特殊约定,用于定义应用的根路由和 HTML 结构。它不是普通的路由文件,因此保持这个命名。

Q2: 可以同时使用 index.tsxpage.tsx 吗?

A: 不建议。配置了 indexToken: 'page' 后,TanStack Router 会查找 page.tsx,忽略 index.tsx。为了保持一致性,应该统一使用一种命名方式。

Q3: 动态路由为什么还是用 $slug 而不是 [slug]

A: 这是 TanStack Router 的核心约定,与 Vite 配置无关。$ 是 TanStack Router 识别动态参数的方式,无法通过配置改变。

Q4: 如何处理路由组(Route Groups)?

A: TanStack Router 使用 _ 前缀表示路由组(不会出现在 URL 中),这与 Next.js 的 (group) 概念类似:

# Next.js
app/(marketing)/about/page.tsx  → /about

# TanStack Start
app/_marketing/about/page.tsx   → /about

Q5: routeFileIgnorePattern 正则太复杂,可以简化吗?

A: 如果你只想识别 page.tsxlayout.tsx,可以使用更简单的模式:

routeFileIgnorePattern: '^(?!.*(?:page|layout|__root)\\.tsx?$).*$'

但上面的模式只匹配 .tsx.ts 文件。原始的正则更全面,也支持 .jsx.js

总结

通过配置 vite.config.ts 中的 TanStack Start 插件选项,我们可以让 TanStack Start 使用与 Next.js App Router 相似的文件命名约定:

  • ✅ 使用 app/ 目录代替 routes/
  • ✅ 使用 page.tsx 代替 index.tsx
  • ✅ 使用 layout.tsx 代替 route.tsx
  • ⚠️ 动态路由仍使用 $param 而非 [param]

这种配置对于从 Next.js 迁移的团队特别有用,降低了学习曲线,同时保留了 TanStack Router 强大的类型安全和灵活性。

参考资源