Supabase Studio 从 Next.js Pages Router 到 TanStack Start 的渐进式迁移实践
【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase
Supabase Studio 是 Supabase 的开源控制台(Dashboard)应用(代码位于 apps/studio),源码规模庞大,历史代码基于 Next.js Pages Router 编写。为了把前端运行时迁移到 Vite + TanStack Start(TanStack Router 的文件路由体系),团队采用了一份双运行时并存的渐进式迁移路线图(即 TANSTACK_MIGRATION.md)。本文将完整还原这份迁移文档的策略、路由清单、兼容层设计与构建层 workaround,并结合仓库真实源码说明每条规则背后的原因。读完本文,你既能理解大规模 SPA 从 Next.js Pages Router 迁移到 TanStack Start 的完整套路(共享布局建模、withAuth迁移、API 路由 shim、chunk 循环依赖防护),也能直接在 Supabase 仓库中对照真实文件逐条验证。
一、迁移背景:为什么采用"双运行时并存"
Supabase Studio 规模大、页面多(组织管理、项目数据库/Auth/Storage/Functions/Logs/设置等产品线),一次性"切文件 + 删代码"风险极高。因此迁移文档(见 TANSTACK_MIGRATION.md 开头 "Runtime model")规定迁移期间Next.js pages router 与 TanStack route tree 同时上线:
- Next.js 的
pages/...目录与 TanStack 的routes/...目录在迁移期同时发布; - 日常跑的是 Vite/TanStack 构建;
build:next/dev:next/start:next等脚本(见 apps/studio/package.json 中 scripts)继续存活,作为兜底运行时,用于回归二分(bisect)与随时切换发布。
由 apps/studio/package.json 可看到两组脚本并存:
"dev:next": "next dev -p ${STUDIO_PORT:-8082}", "build:next": "next build && if [ \"$SKIP_ASSET_UPLOAD\" != \"1\" ]; then ./../../scripts/upload-static-assets.sh; fi", "start:next": "next start -p 8082", "dev:tanstack": "NODE_OPTIONS=--max-old-space-size=8192 vite dev --port ${STUDIO_PORT:-8082}", "build:tanstack":"vite build --mode ${MODE:-production} && pnpm smoke:tanstack", "start:tanstack":"node scripts/serve.js",迁移期间的三条铁律
- 绝不删除任何
apps/studio/pages/...文件。Path A 页面(下文详解)是从pages/中 re-export 默认导出,Next 文件对两个运行时都是"承重墙":删了既破坏 Next 构建,也破坏对应 TanStack 路由。 - 页面 body 的移动与
pages/...删除只允许发生在最后的 cleanup pass——在所有路由都已在routes/...表示、准备彻底退役 Next 运行时之后,这是独立、刻意的阶段,不能揉进单个路由 PR。 - Next 兼容 shim(apps/studio/compat/next/)同样保留到 cleanup pass,与迁移进度无关。
PR 护栏:用 CodeRabbit 规则强制双写检查
因为两套运行时同时发布,任何对pages/...文件的改动都可能让routes/...里的镜像失同步。仓库根目录的 .coderabbit.yaml 中定义了一条作用域为apps/studio/pages/**的path_instructions规则:任何触碰 page 的 PR,CodeRabbit 都会提醒作者检查对应 route 是否需要同步修改。
该规则是"提醒核对、不阻断"(verify-not-block)机制:
- 纯页面 body 改动:Path A 页面是 re-export,自动传播,无需镜像;
- 涉及
getLayout/ layout 包装、staticDataprops、withAuth、重定向路径、或全新页面的改动:必须手工镜像到routes/**(新页面还需要在迁移文档追加清单项); - 规则明确禁止建议删除
pages/**文件(其删除由 FE-3106 跟踪的 cleanup pass 统一处理)。
这条 guardrail 同样是临时脚手架,清理阶段删除pages/**时一并移除(.coderabbit.yaml中该指令注释也指向本迁移文档)。
二、迁移策略:最小 diff 的 re-export
迁移的核心目标是把 URL 归属权切换给 TanStack,而暂不重写页面内部实现。文档对每个页面给出两条路径:
Path A——从pages/re-export(默认)
TanStack route 从apps/studio/pages/...导入页面的 default export,并在一个薄包装组件(route 的component)中渲染:
getLayout被丢弃——由 TanStack 的布局链(pathless 的_app.tsx/_auth.tsx+ sibling-file 布局)负责外层包装;- 页面里 Next 专属 import 通过
compat/next/shim 继续工作; - 由于
NextPageWithLayout把{ dehydratedState: any }声明为必填props,包装组件里要显式传dehydratedState={undefined}。
Path B——直接组件导入
当pages/...文件本质上只是export default SomeComponent(薄包装页面的常见形态)时,跳过中间层,直接在 TanStack route 中导入SomeComponent。典型例子如routes/index.tsx、routes/authorize.tsx(standalone 页面),以及pages/api/ai/docs.ts(本身已是 edge-runtime / Web Response 原生写法,直接 re-export)。
共享布局必须先落地
在逐页迁移前,先把共享布局建好:
- Pathless 布局路由:
_app.tsx、_auth.tsx承载共享外壳,不贡献 URL 段; - Sibling-file 布局:紧挨
segment/目录放置的segment.tsx用<Outlet/>为该目录下子路由提供布局(例如_app/account.tsx包裹_app/account/me.tsx)。不产生route.tsx文件; - 每个产品布局(DatabaseLayout、AuthLayout、SQLEditorLayout……)变成一个 sibling-file 布局。
其他规则
- 新代码直接使用原生 TanStack API(不再用
next/router、next/link);Next compat shim 仅为被 re-export 的旧页面保留; withAuth()HOC 转换为布局/路由上的 TanStackbeforeLoad,尽量在共享布局层级统一处理;- 绝不在迁移中途删除
pages/...; - 本清单不覆盖:
pages/api/**(Next API 路由,单独迁移)、_app.tsx、_document.tsx、_error、以及两个 catch-all(pages/org/_/[[...routeSlug]].tsx、pages/project/_/[[...routeSlug]].tsx,最后专门处理)。
三、布局体系的重建:shell 目录逐层拆解
迁移文档对布局的落位标注了非常细致的"Delta vs plan"(相对原计划的偏差),这些偏差是理解 Studio 页面组合关系的关键。
App shell(pathless 层,账号/组织/通用页面)
| 布局文件 | 内容 | 与计划的偏差(Delta) |
|---|---|---|
routes/_app.tsx | AppLayout + DefaultLayout(读取叶子staticData的defaultLayoutHeaderTitle/hideMobileMenu) | — |
routes/_app/account.tsx | AccountLayout(读accountLayoutTitle) | — |
routes/_app/org.tsx | OrganizationLayout(读orgLayoutTitle),同时包裹/org/index 与/org/$slug/* | 原计划放在_app/org/$slug.tsx,现改为_app/org.tsx;PageLayout仅/org/$slug/index.tsx使用,内联在叶子中 |
routes/_app/new.tsx | 不建;只有_app/new/index.tsx在 _app 下(内联 WizardLayout) | new/$slug是顶层(无 AppLayout),子 shell 不共享状态 |
routes/integrations/vercel.tsx | 仅透传<Outlet/> | 放在顶层而非_app/下;三个 Vercel 叶子各自内联渲染InterstitialLayout,旧的VercelIntegrationWindowLayout已删除 |
Project shell(/project/$ref产品线核心)
这是最复杂的 shell 层。要点如下(对应routes/project/目录下文件):
routes/project/$ref.tsx只提供 DefaultLayout。关键决策:省略ProjectLayoutWithAuth——因为各产品布局(DatabaseLayout、AuthLayout 等)内部已渲染withAuth(... ProjectLayout ...),再加会双重包裹;首页/project/$ref/index.tsx没有产品布局,自己在叶子内包裹ProjectLayoutWithAuth。- 各产品子 shell 均从叶子
staticData读取标题并处理"跳过外层布局"的 opt-out,典型模式是skipXxxLayout: true,用于避免二次包裹(二次包裹会连带withAuth+ProjectLayout双跑):
| Shell | 布局 | 特殊机制 |
|---|---|---|
database.tsx | DatabaseLayout(读databaseLayoutTitle) | — |
database/triggers.tsx | 子 shell:PageLayout + 权限门 + nav 内联自DatabaseTriggersLayout | 复用原组件会在database.tsx外壳内再包一层 DatabaseLayout(二次包裹),故只内联其内层 |
auth.tsx | AuthLayout(读authLayoutTitle) | 支持叶子skipAuthLayout: true(AuthProvidersLayout、AuthEmailsLayout内部已自包 AuthLayout) |
storage.tsx | StorageLayout + StorageBucketsLayout | 默认两层都包;bucket 详情页设skipStorageBucketsLayout: true;/storage/s3用storageBucketsLayout{Title,HideSubtitle}覆盖内层头部 |
functions.tsx | EdgeFunctionsLayout | 支持skipFunctionsLayout: true;functions/$functionSlug.tsx子 shell 提供 EdgeFunctionDetailsLayout 给 5 个 slug 叶子 |
branches.tsx | 仅 BranchLayout | per-page 的 PageLayout 留在各叶子;BranchesPageWrapper/MergeRequestsPageWrapper提升为pages/...文件顶层导出供 route 复用 |
logs.tsx | LogsLayout | logs/index设skipLogsLayout: true(UnifiedLogs 自己处理 ProjectLayout);原pages/.../logs/index.tsx把内联<DefaultLayout>移入getLayout避免重复 |
advisors.tsx | AdvisorsLayout | 支持skipAdvisorsLayout: true;rules 子 shell 扫描整个 match 链 |
advisors/rules.tsx | 子 shell,内联AdvisorRulesLayout内层 | 原组件自带 DefaultLayout + AdvisorsLayout,复用会双包两层,故只内联内层 |
settings.tsx | SettingsLayout | 支持skipSettingsLayout: true(settings/api纯重定向页);settings/api-keys.tsx子 shell 提供 ApiKeysLayout;jwt/index内联 JWTKeysLayout |
integrations.tsx | ProjectIntegrationsLayout | 4 个叶子共享同一布局,shell 只包一次<Outlet/> |
sql.tsx | EditorBaseLayout + SQLEditorLayout | 四个叶子布局 props 相同,外壳硬编码;EditorBaseLayout 自带 ProjectLayoutWithAuth,SQLEditorLayout 另有withAuthHOC(认证跑两次但不重复渲染) |
editor.tsx | EditorBaseLayout + TableEditorLayout | 三个叶子共享;TableEditorLayout happy path 只是 fragment + banner,仅在无权限分支才包 ProjectLayoutWithAuth |
Auth shell(pathless):routes/_auth.tsx提供 AuthenticationLayout,承载/sign-in、/sign-up、MFA、找回密码、合作伙伴登录等认证相关页面。
用staticData传递页面元数据
上述布局反复出现一个关键词:staticData。TanStack Router 允许在 route 上声明静态数据,叶子路由通过staticData声明标题类 props(databaseLayoutTitle、authLayoutTitle、orgLayoutTitle、hideMobileMenu等),父级 shell 布局读取后决定渲染什么标题/菜单。这是对 NextNextPageWithLayout+getLayout模式的直接替代——迁移时把页面标题、是否隐藏移动端菜单、是否跳过某层布局等"页面级装饰信息"统一挪进staticData,让布局链"数据驱动"而非"组件嵌套驱动",从而根治多层组件互相包裹造成的二次包裹问题。
四、页面迁移清单导读
迁移文档的主体是一份庞大的路由清单。概括其分类结构(每条均标记 Path A/B 与完成状态):
- App shell
/account/*:me / security / audit / tokens(含 scoped)。 - App shell
/org/$slug/*:index / apps / audit / audit-log-drains / billing / documents / general / integrations / security / sso / team / usage / private-apps / webhooks(含$endpointId),外加_app/org/index.tsx(重定向页)。其中 audit-log-drains 等页面内联 OrganizationSettingsLayout。 - App shell 顶层页面:
organizations.tsx、_app/new/index.tsx(内联 WizardLayout,staticData设defaultLayoutHeaderTitle: 'New organization'+hideMobileMenu: true)、new/$slug.tsx、aws-marketplace-onboarding.tsx、claim-project.tsx、join.tsx、_app/support/new.tsx、_app/support/link.tsx。后三者因页面自带独立布局(自绘<Head>/<main>/居中 div)而放在根目录,避免被 AppLayout 包裹造成行为变化。 - integrations:Vercel 的 install / marketplace choose-project / deploy-button new-project,以及 GitHub authorize(原 Next 页面无 getLayout,故放顶层避免行为变化)。
- Project shell下按产品线组织:home、
/api/*、/database/*(schemas/extensions/functions/indexes/migrations/policies/roles/settings/types/column-privileges/tables/publications/replication/triggers/backups)、/auth/*、/storage/*、/realtime/*、/workers/*、/functions/*、/branches/*、/logs/*(约 20 个子页含 explorer)、/observability/*、/advisors/*、/settings/*、/integrations/*、/sql/*、/editor/*、/explorer/*。 - Auth shell:sign-in、sign-up、sign-in-sso、sign-in-partner、sign-in-mfa、forgot-password(+mfa)、reset-password、cli/login、partners/stripe/projects/login。
- Standalone(无共享 shell):
routes/index.tsx(纯重定向根路由,镜像next.config.ts中redirects()逻辑:平台版进/org、deep-link?next=new-project进/new/new-project、self-hosted 进/project/default);authorize / redeem / logout / maintenance / verify-email。 - 错误页:
__root.tsx的notFoundComponent接到pages/404.tsx,errorComponent接到pages/500.tsx,并保留react-error-boundary的 Sentry 上报(scope.setTag('routerErrorComponent', true)),使路由级错误(在树内 boundary 挂载前的 loader/组件渲染失败)仍被记录。
这里有一个值得注意的文件命名决策(迁移文档 "Deferred / revisit" 部分):两个 catch-all(org 与 project 的[[...routeSlug]])落地时没有用routes/org/[_]/index.tsx这种 index-file 形式,而是采用path-as-filename形式routes/org.[_].tsx+routes/org.[_].$.tsx。原因是一个 router-generator 的 bug:当index.tsx含方括号转义的父段时,getRouteNodes.js会把originalRoutePath整个抹掉、丢失转义信息,导致_被当作 pathless 段剥离。path-as-filename 形式让末段保持非 index,绕开该 bug 分支。这也是为什么根目录下会出现org.[_].tsx、project.[_].tsx这类"非常规"文件名。
五、API 路由迁移:shim + re-export 与toWebHandler
Studio 有大量 Next.js API routes(pages/api/**)。迁移文档的 API 策略是shim + re-export:compat/next/api.ts暴露toWebHandler(nextHandler),把(req, res) => …形态的 Next handler 适配成 TanStack Start 的 Web-fetch handler;每个routes/api/...文件导入pages/api/...的 default export,包一层toWebHandler后用createFileRoute(...).server.handlers注册。apiWrapper与apiAuthenticate原样不动,它们在 shim 内执行,看到的是 NextApiRequest 形状的req与代理res。
路径约定(对应仓库中routes/api/真实文件):
pages/api/foo/[bar]/baz.ts → routes/api/foo/$bar/baz.ts pages/api/foo/[[...slug]].ts → routes/api/foo/$.tsshim 覆盖的能力面
从 apps/studio/compat/next/api.ts 源码看,buildRequest/buildResponse完整复刻了 pages-router handler 的两类用法:
- Buffered 响应:
res.status/setHeader/json/send/write/end累积进缓冲,handler 返回时finalize()拼装成一个Response。 - Streaming 响应:handler 调用
res.writeHead(status, headers?)(或res.flushHeaders())即切入流模式——打开 WebReadableStream,先把已缓冲的 chunk 冲入,后续res.write(chunk)实时入队,res.end()关闭。finalize()先返回Response,handler 随后仍可继续推 chunk。这正是 AI SDK 的result.pipeUIMessageStreamToResponse(res, …)能逐 token 流式输出到浏览器的原因。 - 客户端 abort:Web
Request.signal被接通为req.on('close' | 'aborted', …)——依赖这些事件调用abortController.abort()的 AI handler 照常工作。 - EventEmitter 表面:
req.on/once/off/emit中只有close/aborted真实;其他事件名被接受但 no-op。res.on等为 no-op stub,避免 pipe 辅助函数挂drain/close/error监听时崩溃。 - Body 解析:JSON 与
application/x-www-form-urlencoded解析进req.body,其余一律 raw text;multipart 入站未实现(studio 没有读取 multipart 的 handler)。
绕过 shim 的特例
迁移文档明确列出两条因"Web 原生写更简单"而绕过 shim 的路由,仓库routes/api/下可直接对照:
routes/api/v1/projects/$ref/functions/$slug/body.ts:multipart 流式出站(产物下载)。把 Response body 构建为ReadableStream,每个产物文件经Readable.toWeb(createReadStream(...))转换后逐 chunk 拉入流。routes/api/mcp/index.ts:直接使用 MCP SDK 的WebStandardStreamableHTTPServerTransport——handleRequest(request)直接返回Response。pages/api/ai/docs.ts:本来就是 edge-runtime / Web-Response 原生,直接 re-export,无 shim。
六、Next 兼容 shim 面:compat/next/全貌
只要还有pages/...文件承重,这些 shim 就必须活着。仓库 apps/studio/compat/next/ 目录结构即文档所列 shim 的落地:
router.ts——为 hook 调用方提供useRouter()(由 TanStack 的useRouter+useLocation+useMatches+useParams+useSearch拼装),另有 default export(SingletonRouter形状)给唯一一个模块作用域import router from 'next/router'的消费方(Support/DiscordCTACard 在 React 外读router.basePath)。源码注释里展示了大量"语义对齐"细节:TanStack route id 的$param要转回 Next 的[param]路径模式;要剥掉 TanStack 给 index route 追加的尾部斜杠——否则router.pathname.split('/')[3]对 index 页返回''而非undefined,项目侧边栏的高亮判断就失效;还要剥掉_app/_auth等 layout 段,否则下游按pathname.split('/')[N]取段会错位。_router-events.ts——把router.events.on(event, handler)适配到router.subscribe(tsEvent, …),转发 Next 的(url, { shallow })参数,映射routeChangeStart/routeChangeComplete/beforeHistoryChange/hashChangeStart/hashChangeComplete。已知缺口:Next "从 routeChangeStart 抛异常来取消导航"的模式无法支持(subscribe是 fire-and-forget),依赖它的usePreventNavigationOnUnsavedChanges需要另行迁移到 TanStack 的useBlocker(列入清理清单)。api.ts——toWebHandler(nextHandler),即上一节的 API shim。link.tsx、navigation.ts、dynamic.tsx、image.tsx、legacy/image.tsx、script.tsx、head.tsx、server.ts——对 studio 所 import 的next/*模块做 drop-in 替换。全部经 apps/studio/vite.config.ts 中的nextCompat()插件 alias 收口,并配ssr.noExternal: [/^next(\/|$)/]保证 shim 一定胜过真实 Next 包。
nextCompat()插件(vite.config.ts中定义)本身还承担迁移守门人角色:应用源码若 import 一个未被 shim 的next/*id,构建直接抛错提示去补 shim 或用框架无关替代;node_modules内 import(如@sentry/nextjs探入 next)不受影响。也就是说,构建期就能拦截任何漏网的 Next import。
七、构建 / 打包层的坑与 workaround
apps/studio/vite.config.ts 是本次迁移工程量最大的单文件,承载了六类"为迁移而存在"的构建期防护。
1.manualChunks固定:消除 chunk 级循环依赖
Rolldown 对 studio 庞大依赖图的分块方式会产生chunk 级循环——组件 chunk 从"会(传递地)反向 import 它"的 chunk 里导入了绑定,浏览器端表现为模块加载期的TypeError: <name> is not a function。配置里的固定项:
class-variance-authority——TreeView 单独成 chunk 并从uichunk importcva,而ui又反向 re-export TreeView,导致 TreeView 顶层cva(...)在 SSR 预渲染时拿到 undefined;lucide-react——防止图标被按图标拆成 importcreateLucideIcon的 chunk(canary:folder-open-<hash>.js);react-vendor(react + react-dom + scheduler + jsx-runtime)——必须先于lucide-react固定,否则 Rolldown 会把 React 卷进 lucide chunk 做 CJS interop,把 live-binding 打散到整个依赖图(canary:Alert-<hash>.js)。
2.assertNoChunkCycles插件:把循环变成构建错误
该插件在generateBundle阶段对产物 chunk 图跑Tarjan 强连通分量(SCC),发现任何未知 chunk 循环就 fail 构建。历史遗留的 CVA 循环按 chunk basename 白名单放行(KNOWN_CHUNK_CYCLES常量,其中可看到['LoadingLine', 'TreeView', 'ui']等变体),任何新增循环都会被阻断并提示去vite.config.ts注册。文档特别强调:这个插件迁移完成后也应保留——它不是 Next 相关 shim,而是对整类 bug 的通用防护,只需在底层循环消除后清空白名单。
3.@sentry/nextjs→@sentry/reactalias
resolve.alias把裸@sentry/nextjs导入重写到 compat/sentry-nextjs.ts,后者 re-export@sentry/react(同版本,@sentry/nextjs客户端本就包裹它),并补了 Next-only API 的显式替身(captureRouterTransitionStart、captureRequestError、withSentryConfig)。原因:@sentry/nextjs客户端入口 import 了next/dist/shared/lib/constants,其模块作用域会求值...(process?.features?.typescript ? ['next.config.mts'] : [])——可选链保护不了未声明的process标识符,导致每个含它的客户端 chunk(canary:表格编辑器)在模块加载时抛ReferenceError: process is not defined。dev 不受影响(dev 管线 shim 了process),只在生产/测试构建暴露。应用源码继续 import@sentry/nextjs,因此 Next 构建(build:next)不受影响。
4. GraphiQL Monaco worker:setup-workers/webpack→setup-workers/vite
GraphiQLTab.tsx源码 import 的是graphiql/setup-workers/webpack,它用new Worker(new URL('monaco-editor/...', import.meta.url))注册MonacoEnvironment.getWorker——这是 webpack/turbopack 会在构建期重写的 URL 形式;Vite 不会改写new URL里裸模块说明符,worker URL 404 后 Monaco 退回主线程跑 json/editorWorkerService/graphql worker(控制台出现 "Could not create web worker(s)..." 警告)。graphiqlViteWorkers插件在客户端构建把该 import 解析到 graphiql 自带的setup-workers/vite变体(同三个 worker,走 Vite?workerimport),SSR 解析不受影响;应用源码的 import 说明符保持.../webpack以保 Next 构建。整个 setup-workers 链还在optimizeDeps.exclude——Rolldown 依赖预构建加载不了?workerid(UNLOADABLE_DEPENDENCY),须让模块走常规 transform 管线由 Vite 内置 worker 插件处理。
5. Raw-text imports:*.md+public/deno/*.d.ts(rawTextLoader)
Next 侧由next.config.ts的turbopack.rules把*.md与 Deno 类型文件按 raw text 模块提供;rawTextLoader插件为 Vite 管线复刻该行为:
*.md:普通transform,default export 文件文本(用于static-data/integrations/*/overview.md经static-data/integrations/overviews.ts的 literal-import registry 引用);- 两个 Deno
.d.ts(public/deno/edge-runtime.d.ts、public/deno/lib.deno.d.ts,被components/ui/AIEditor作为 Monaco extra libs):用精确说明符白名单解析到\0-virtual id 并由loadhook 提供文本。它们不能走transform——Rolldown 原生依赖扫描器会跳过 JS 插件 hook,把 TSdeclaration语法(如get stdin(): ...;)当运行时代码硬解析而整体失败,连带整个依赖预构建崩溃。注释与文档共同强调:绝不要把白名单放宽到*.d.ts——全局劫持声明文件解析会破坏所有"JS 旁带.d.ts"的包。AIEditor/index.tsx里的as string强转则让 tsc 不去把.d.ts当声明文件解析(TS2846),擦除后两个 bundler 都能静态分析为普通字面量。
6. 其他迁移期构建改动
pnpm-workspace.yaml的 catalog 新增@tanstack/react-router、@tanstack/react-start、@tanstack/react-table,让 studio 与库保持对齐;react-query暂不入 catalog——studio/docs/library 三个消费方在 5.x 不同 range,统一是单独决策。- studio 的
dev:tanstack脚本设了NODE_OPTIONS=--max-old-space-size=8192——watch 模式下 Vite 的 Rolldown-RC 前端啃 studio 模块图会顶到默认 4 GB 上限(该配置同样可见于 apps/studio/package.json)。
八、清理清单:收尾阶段的路线图
当每个pages/...文件都被删除后,文档列出清理项(内部跟踪 FE-3106),可视为"迁移完成的定义":
routes/index.tsx的重定向从href(整页刷新)改为to——目标现已全部在 TanStack 树内;usePreventNavigationOnUnsavedChanges从router.events.on('routeChangeStart', …)的 throw-to-cancel 模式迁移到 TanStackuseBlocker;- 删除两个 catch-all 页中的
_splat/routeSlug归一化块(仅为让两运行时挂载同一 body 而存在); - 从
__root.tsx移除RouteValidationWrapper与next/routercompat shim 使用; compat/next/目录整体删除(当工作区源码不再有next/*import 时);- 解除
manualChunks固定(class-variance-authority、lucide-react、react-vendor)——前提是packages/ui的结构性修复落地;assertNoChunkCycles保留,仅清空KNOWN_CHUNK_CYCLES; - 删除
pages/_app.tsx、pages/_document.tsx、pages/_error.jsx、pages/500.tsx、pages/404.tsx(Next-only catch-all;TanStack 等价物在__root.tsx); - 从 apps/studio/package.json 移除
dev:next/build:next/start:next脚本; - 移除 .coderabbit.yaml 中
apps/studio/pages/**的path_instructionsguardrail; - 从 apps/studio/AGENTS.md 删除 "TanStack Start migration" 一节(仅双运行时共存期适用);
- 删除本迁移文档自身。
九、从这份迁移清单可以复用哪些方法论
阅读 TANSTACK_MIGRATION.md 最大的收获,是它把"大规模前端框架迁移"拆成了可逐步验证的工程步骤:
- 双运行时 + 逐路由所有权移交:老代码继续承重,新树逐步接管 URL;用"最小 diff 的 re-export"先把 URL 归属切换过来,body 迁移推迟到单独阶段——避免单次重构同时承担"路由语义变化"与"组件内部重写"两个风险。
- 以布局链重构替代
getLayout:共享 shell 落地前置、每个产品布局一个 sibling-file layout,用staticData承载标题/隐藏菜单/跳过某层布局等页面元数据;"二次包裹"(double-wrap)是被反复识别并规避的头号反模式——连withAuth都会随之双跑。 - API 兼容层做"能力面"分析而非逐函数重写:
toWebHandler清楚列出 buffered / streaming / client-abort / EventEmitter 表面 / body 解析这五类 pages-router handler 真实用到的能力,再决定哪些能 shim、哪些必须 Web 原生重写(multipart 下载、MCP transport)。 - 把构建期隐患变成构建期错误:unshimmed Next import 直接报错、chunk 循环用 Tarjan SCC 扫描 fail 构建、Sentry/worker/raw-text 等坑全部沉淀为带 canary 例子的注释——这些注释本身就是一份极好的"迁移踩坑手册"。
- 把"完成"定义成可勾选的清理清单:兜底脚本、shim、guardrail、catalog 变更全部有明确去处,避免迁移结束留一堆死代码。
如果你正计划把大型 Next.js Pages Router 应用迁到 TanStack Start,建议先通读这份文档的运行时模型与共享布局章节,再对照 apps/studio/vite.config.ts 的各插件注释和 apps/studio/compat/next/ 的实现,理解每一条规则背后对应的真实故障模式——迁移的成败往往不取决于路由文件搬得多快,而取决于这些"边界与兜底"是否提前想清楚。
【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考