# TanStack Start migration — route checklist
Temporary tracking doc. Delete once migration is done.
**Runtime model — Next.js and TanStack Start run side-by-side**
Throughout this migration both runtimes coexist in the same workspace:
- The Next.js pages router (`pages/...`) and the TanStack route tree
(`routes/...`) ship **at the same time**. The Vite/TanStack build is
what we run today; the Next build (`build:next` / `dev:next` scripts
in `apps/studio/package.json`) stays alive as a fallback so we can
bisect regressions and ship either runtime if needed.
- **Do not delete any `apps/studio/pages/...` file during the per-route
migration.** Path A pages re-export their `pages/` default export, so
the Next file is load-bearing for both runtimes. Removing it breaks
the Next build and breaks the TanStack route too.
- Body-moves and `pages/...` deletion happen **only in the final
cleanup pass**, after every route is represented in `routes/...` and
we're ready to retire the Next runtime entirely. That's a separate,
deliberate phase — not something to fold into individual route PRs.
- Same rule for the Next compat shims (`apps/studio/compat/next/`):
they stay until the cleanup pass, regardless of how many routes have
moved.
**PR guardrail — mirror page edits into their route**
Because both runtimes ship at once, any change to a `pages/...` file can
leave its `routes/...` mirror stale. A CodeRabbit `path_instructions`
rule (`.coderabbit.yaml`, scoped to `apps/studio/pages/**`) posts a
review reminder on every PR that touches a page, prompting the author to
check whether the corresponding route needs the same change. It's a
verify-not-block reminder: pure body edits on re-export (Path A) pages
propagate automatically, but layout/`getLayout`, `staticData` props,
`withAuth`, redirect-path, or brand-new-page changes must be mirrored by
hand. This guardrail is temporary scaffolding — remove it in the cleanup
pass when `pages/**` is deleted (tracked by FE-3106).
**Strategy — minimum-diff re-export**
The goal is to flip URL ownership to TanStack without rewriting page internals yet. For each page we pick one of two paths:
- **Path A — re-export from `pages/`** (default). The TanStack route imports the page's default export from `apps/studio/pages/...` and renders it inside a thin wrapper component used as the route's `component`. `getLayout` is dropped on the floor — the TanStack layout chain (pathless `_app.tsx` / `_auth.tsx` + sibling-file layouts) handles wrapping instead. The page's Next-specific imports keep working via the `compat/next/` shim. Because `NextPageWithLayout` declares `{ dehydratedState: any }` as required props, pass `dehydratedState={undefined}` in the wrapper.
- **Path B — direct component import**. When the `pages/...` file is essentially `export default SomeComponent` re-exporting a component from elsewhere (typical for thin page wrappers), skip the middle-man and import `SomeComponent` directly in the TanStack route.
We still need to land the shared layouts up-front:
- Pathless layout routes (`_app.tsx`, `_auth.tsx`) hold shared shells without contributing URL segments.
- Sibling-file layouts: `segment.tsx` next to a `segment/` directory provides the layout with `` for children in that directory (e.g. `_app/account.tsx` wraps `_app/account/me.tsx`). No `route.tsx` files.
- Each product layout (DatabaseLayout, AuthLayout, SQLEditorLayout, …) becomes one sibling-file layout.
Once every page is represented in `routes/`, we do a second pass to properly move the page body into the route file and delete `pages/...`. Path-tracking (A vs B) below tells us which pages still have live Next files we need to eliminate.
Other rules:
- New code uses native TanStack APIs directly (no `next/router`, no `next/link`). The Next compat shim stays in place for pages we re-export.
- `withAuth()` HOC → TanStack `beforeLoad` on the containing route/layout. Apply this at shared-layout level where possible.
- **Never** delete a `pages/...` file mid-migration — both runtimes need to keep working. Body-moves and Next-file deletions are reserved for the cleanup pass at the very end, after every entry in this checklist is `[x]`. See "Runtime model" above.
- Not migrated via this list: `pages/api/**` (Next API routes — separate migration), `_app.tsx`, `_document.tsx`, `_error`, `pages/org/_/[[...routeSlug]].tsx`, `pages/project/_/[[...routeSlug]].tsx` (catch-alls — revisit at the end).
**Legend**
- `[ ]` not started
- `[~]` in progress
- `[x] A` done — re-exported from `pages/...` (Next file still exists, needs body-move later)
- `[x] A→done` done & body moved — Next file deleted
- `[x] B` done — direct component import, no Next file involved (or Next file already deletable)
---
## Shared layouts
These are the layout-only TanStack files. Most hold a single product layout component.
### App shell (pathless)
- [x] `routes/_app.tsx` — AppLayout + DefaultLayout (reads `defaultLayoutHeaderTitle`/`hideMobileMenu` from leaf `staticData`)
- [x] `routes/_app/account.tsx` — AccountLayout (reads `accountLayoutTitle` from leaf `staticData`)
- [x] `routes/_app/org.tsx` — OrganizationLayout (reads `orgLayoutTitle` from leaf `staticData`). **Delta vs plan:** placed at `_app/org.tsx` (wraps both `/org/` index and `/org/$slug/*`) instead of `_app/org/$slug.tsx`. PageLayout stays inline on `/org/$slug/index.tsx` since only that one route uses it.
- [x] `routes/_app/new.tsx` — skipped; only `_app/new/index.tsx` lives under \_app (inlines WizardLayout). `new/$slug` is top-level (no AppLayout) so a sub-shell would not actually share state.
- [x] `routes/integrations/vercel.tsx` — passthrough `Outlet` only (no shared window layout). **Delta vs plan:** placed at top-level rather than under `_app/`. All three Vercel leaves (install, marketplace choose-project, deploy-button new-project) render their own `InterstitialLayout` inline; the old `VercelIntegrationWindowLayout` was removed.
### Project shell
- [x] `routes/project/$ref.tsx` — DefaultLayout only. **Delta vs plan:** ProjectLayoutWithAuth omitted from the shell because product layouts (DatabaseLayout, AuthLayout, StorageLayout, …) already render `withAuth(... ProjectLayout ...)` internally — adding it here would double-wrap. The home page (`/project/$ref/index.tsx`) wraps itself in `ProjectLayoutWithAuth` since it has no product layout.
- [x] `routes/project/$ref/database.tsx` — DatabaseLayout (reads `databaseLayoutTitle` from leaf `staticData`)
- [x] `routes/project/$ref/database/triggers.tsx` — sub-shell with `PageLayout` + permission gate + nav items, inlined from `DatabaseTriggersLayout`. **Delta vs plan:** the existing `DatabaseTriggersLayout` component wraps `` internally, so re-using it inside the database.tsx shell would double-wrap. Inlined the inner part instead; the Next-side component is left untouched (still used by the `pages/...` files we re-export).
- [x] `routes/project/$ref/database/pipelines.tsx` — sub-shell providing `PipelineRequestStatusProvider`, mirrors `PipelinesLayout` on the Next side. Sets `databaseLayoutTitle: 'Pipelines'` for the whole subtree so the provider stays a single instance across navigation between the Pipelines list and detail routes. Legacy Replication routes are retained only for redirects, while the replica detail route redirects to Infrastructure.
- [x] `routes/project/$ref/auth.tsx` — AuthLayout (reads `authLayoutTitle` from leaf `staticData`). **Delta vs plan:** shell honours a `skipAuthLayout: true` opt-out in `staticData` for leaves whose own body or sub-layout already wraps in `AuthLayout` (`AuthProvidersLayout`, `AuthEmailsLayout`, `pages/.../auth/third-party.tsx`) — without it those routes would double-wrap (which also doubles `withAuth` + `ProjectLayout`).
- ~~`routes/project/$ref/auth/templates.tsx` — AuthEmailsLayout~~ **Delta vs plan: not landed.** A unified `templates.tsx` sub-shell would force `templates/$templateId.tsx` (which uses plain `AuthLayout`, not `AuthEmailsLayout`) into the wrong wrapping. Instead `templates/index.tsx` and `auth/smtp.tsx` each set `skipAuthLayout: true` and wrap themselves in `AuthEmailsLayout`; `templates/$templateId.tsx` uses the standard auth shell with `authLayoutTitle: 'Emails'`.
- [x] `routes/project/$ref/storage.tsx` — StorageLayout + StorageBucketsLayout (reads `storageLayoutTitle`, optional `skipStorageBucketsLayout`, `storageBucketsLayoutTitle`, `storageBucketsLayoutHideSubtitle` from leaf `staticData`). **Delta vs plan:** the shell wraps in BOTH StorageLayout and StorageBucketsLayout by default — every storage page except bucket-detail pages uses both. Bucket-detail pages set `skipStorageBucketsLayout: true`. `/storage/s3` uses `storageBucketsLayout{Title,HideSubtitle}` to override the inner header.
- [x] `routes/project/$ref/realtime.tsx` — RealtimeLayout (reads `realtimeLayoutTitle` from leaf `staticData`)
- [x] `routes/project/$ref/functions.tsx` — EdgeFunctionsLayout (reads `functionsLayoutTitle` from leaf `staticData`). Honours `skipFunctionsLayout: true` opt-out for the `$functionSlug` subtree, whose `EdgeFunctionDetailsLayout` already wraps `EdgeFunctionsLayout` internally — same pattern as auth.tsx. Sub-shell at `routes/project/$ref/functions/$functionSlug.tsx` provides `EdgeFunctionDetailsLayout` for all 5 slug leaves (reads `edgeFunctionDetailsTitle` from leaf staticData).
- [x] `routes/project/$ref/branches.tsx` — BranchLayout only. **Delta vs plan:** the per-page `PageLayout` (with different titles + primary/secondary actions) stays in each leaf. Hoisted `BranchesPageWrapper` and `MergeRequestsPageWrapper` to top-level exports in their respective `pages/...` files so the route files can import + re-use the same wrapping.
- [x] `routes/project/$ref/logs.tsx` — LogsLayout (reads `logsLayoutTitle` from leaf staticData). Honours `skipLogsLayout: true` for `logs/index` (page handles its own ProjectLayout-wrapped content for the UnifiedLogs / no-permission cases). Refactored `pages/.../logs/index.tsx` to move the inline `` into `getLayout` so it isn't duplicated when the TanStack project shell already provides DefaultLayout.
- [x] `routes/project/$ref/observability.tsx` — ObservabilityLayout (reads `observabilityLayoutTitle` from leaf staticData)
- [x] `routes/project/$ref/compute.tsx` — ComputeLayout (reads `computeLayoutTitle` from leaf `staticData`). Flag-gated: `ComputeLayout` itself redirects to the project home when `useFlag('compute')` is off, so the shell needs no extra guard.
- [x] `routes/project/$ref/advisors.tsx` — AdvisorsLayout (reads `advisorsLayoutTitle` from leaf staticData). Honours `skipAdvisorsLayout: true` opt-out for the rules sub-shell, which provides its own AdvisorsLayout-less-DefaultLayout wrap. Scans whole match chain (same pattern as functions.tsx).
- [x] `routes/project/$ref/advisors/rules.tsx` — sub-shell that inlines the inner body of `AdvisorRulesLayout` (AdvisorsLayout + PageLayout with title/tabs/feature-preview badge), minus the outer DefaultLayout (already provided by the parent project shell). Sets `skipAdvisorsLayout: true` on its own staticData. **Delta vs plan:** the existing `AdvisorRulesLayout` component wraps in DefaultLayout + AdvisorsLayout internally, so reusing it as-is would double-wrap both. Inlined the inner part; the Next-side component is untouched.
- [x] `routes/project/$ref/settings.tsx` — SettingsLayout (reads `settingsLayoutTitle` from leaf staticData). Honours `skipSettingsLayout: true` for `settings/api` (redirect-only page). Adds a sub-shell at `routes/project/$ref/settings/api-keys.tsx` providing `ApiKeysLayout` for both api-keys leaves; `jwt/index` wraps in `JWTKeysLayout` inline since `jwt/legacy` doesn't share it.
- [x] `routes/project/$ref/integrations.tsx` — ProjectIntegrationsLayout (no staticData; all 4 leaves share identical layout). Layout is `withAuth(({ children }) => {children})`, so the shell just wraps `` once.
- [x] `routes/project/$ref/sql.tsx` — EditorBaseLayout + SQLEditorLayout. Twin of editor.tsx; all four leaves share identical layout props so the shell hardcodes them (no `staticData` overrides). EditorBaseLayout wraps in ProjectLayoutWithAuth; SQLEditorLayout adds its own `withAuth` HOC but no extra ProjectLayout — same shape as the table editor (auth check runs twice but no double render).
- [x] `routes/project/$ref/editor.tsx` — EditorBaseLayout + TableEditorLayout. All three leaves share identical layout props so the shell hardcodes them (no `staticData` overrides). EditorBaseLayout wraps in `ProjectLayoutWithAuth` internally; TableEditorLayout's happy path is just a fragment + side-effect (banner) and only wraps in `ProjectLayoutWithAuth` on its no-permission branch — same as Next, no double-wrap in normal use.
### Auth shell (pathless)
- [x] `routes/_auth.tsx` — AuthenticationLayout
---
## Pages
### App shell — `/account/*`
- [x] A `routes/_app/account/me.tsx` ← `pages/account/me.tsx`
- [x] A `routes/_app/account/security.tsx` ← `pages/account/security.tsx`
- [x] A `routes/_app/account/audit.tsx` ← `pages/account/audit.tsx`
- [x] A `routes/_app/account/tokens/index.tsx` ← `pages/account/tokens.tsx`
- [x] A `routes/_app/account/tokens/scoped.tsx` ← `pages/account/tokens/scoped.tsx`
### App shell — `/org/$slug/*`
- [x] A `routes/_app/org/$slug/index.tsx` ← `pages/org/[slug]/index.tsx`
- [x] A `routes/_app/org/$slug/apps.tsx` ← `pages/org/[slug]/apps.tsx`
- [x] A `routes/_app/org/$slug/audit.tsx` ← `pages/org/[slug]/audit.tsx`
- [x] A `routes/_app/org/$slug/audit-log-drains.tsx` ← `pages/org/[slug]/audit-log-drains.tsx` (wraps in OrganizationSettingsLayout inline)
- [x] A `routes/_app/org/$slug/billing.tsx` ← `pages/org/[slug]/billing.tsx`
- [x] A `routes/_app/org/$slug/documents.tsx` ← `pages/org/[slug]/documents.tsx`
- [x] A `routes/_app/org/$slug/general.tsx` ← `pages/org/[slug]/general.tsx`
- [x] A `routes/_app/org/$slug/integrations.tsx` ← `pages/org/[slug]/integrations.tsx`
- [x] A `routes/_app/org/$slug/security.tsx` ← `pages/org/[slug]/security.tsx`
- [x] A `routes/_app/org/$slug/sso.tsx` ← `pages/org/[slug]/sso.tsx`
- [x] A `routes/_app/org/$slug/team.tsx` ← `pages/org/[slug]/team.tsx`
- [x] A `routes/_app/org/$slug/usage.tsx` ← `pages/org/[slug]/usage.tsx`
- [x] A `routes/_app/org/$slug/private-apps/index.tsx` ← `pages/org/[slug]/private-apps/index.tsx`
- [x] A `routes/_app/org/$slug/webhooks/index.tsx` ← `pages/org/[slug]/webhooks/index.tsx`
- [x] A `routes/_app/org/$slug/webhooks/$endpointId.tsx` ← `pages/org/[slug]/webhooks/[endpointId].tsx`
- [x] A `routes/_app/org/index.tsx` ← `pages/org/index.tsx` (redirect)
### App shell — top-level pages
- [x] A `routes/_app/organizations.tsx` ← `pages/organizations.tsx` (page default already withAuth-wrapped; PageLayout wraps body)
- [x] `routes/_app/new/index.tsx` ← `pages/new/index.tsx` (inlines WizardLayout; sets `defaultLayoutHeaderTitle: 'New organization'` + `hideMobileMenu: true` on staticData). **Delta vs plan:** no `_app/new.tsx` sub-shell — `new/$slug` doesn't fit under \_app and uses a different inner wrapper (PageLayout), so a shared shell wouldn't share anything.
- [x] A `routes/new/$slug.tsx` ← `pages/new/[slug].tsx` **Delta vs plan:** placed at top-level rather than under `_app/` — Next getLayout omits AppLayout and uses PageLayout (not WizardLayout) inside DefaultLayout, so leaf inlines the full DefaultLayout + PageLayout wrap itself.
- [x] A `routes/aws-marketplace-onboarding.tsx` ← `pages/aws-marketplace-onboarding.tsx` **Delta vs plan:** placed at root rather than under `_app/` — page uses its own `LinkAwsMarketplaceLayout` and doesn't want `AppLayout` + `DefaultLayout` wrapping.
- [x] A `routes/claim-project.tsx` ← `pages/claim-project.tsx` **Delta vs plan:** placed at root rather than under `_app/` — page uses its own `` + `` layout and doesn't want `AppLayout` + `DefaultLayout` wrapping.
- [x] A `routes/join.tsx` ← `pages/join.tsx` **Delta vs plan:** placed at root rather than under `_app/` — page uses a centered-div layout and doesn't want `AppLayout` + `DefaultLayout` wrapping.
- [x] A `routes/stripe-atlas-application.tsx` ← `pages/stripe-atlas-application.tsx` (no `withAuth` — reachable logged in and logged out; uses `InterstitialLayout`, so no `AppLayout` + `DefaultLayout` wrapping)
- [x] `routes/_app/support/new.tsx` ← `pages/support/new.tsx` (sets `hideMobileMenu: true` staticData; existing page is `withAuth`-wrapped so no beforeLoad migration needed yet)
- [x] `routes/_app/support/link.tsx` ← `pages/support/link.tsx`
### App shell — integrations
- [x] A `routes/integrations/vercel/install.tsx` ← `pages/integrations/vercel/install.tsx`
- [x] A `routes/integrations/vercel/$slug/marketplace/choose-project.tsx` ← `pages/integrations/vercel/[slug]/marketplace/choose-project.tsx`
- [x] A `routes/integrations/vercel/$slug/deploy-button/new-project.tsx` ← `pages/integrations/vercel/[slug]/deploy-button/new-project.tsx`
- [x] A `routes/integrations/github/authorize.tsx` ← `pages/integrations/github/authorize.tsx` **Delta vs plan:** placed at top-level rather than under `_app/` — Next page has no getLayout (renders bare), so adding AppLayout/DefaultLayout via \_app would be a behaviour change.
### Project shell — home
- [x] A `routes/project/$ref/index.tsx` ← `pages/project/[ref]/index.tsx` (route wraps in `ProjectLayoutWithAuth` itself — see shell delta above)
- [x] `routes/project/$ref/merge.tsx` ← `pages/project/[ref]/merge.tsx` (leaf wraps body in `ProjectLayoutWithAuth`; parent `project/$ref.tsx` shell provides DefaultLayout)
- [x] `routes/project/$ref/explorer.tsx` — converted from a leaf into a shell (`ExplorerLayout` + `Outlet`) to host the new `/explorer/notebook/$id` leaf; parent `project/$ref.tsx` shell still provides DefaultLayout.
### Project shell — `/api/*`
- [x] `routes/project/$ref/api/index.tsx` ← `pages/project/[ref]/api/index.tsx` (redirect-only page; no extra wrap needed beyond the parent DefaultLayout shell)
### Project shell — `/database/*`
- [x] A `routes/project/$ref/database/schemas.tsx` ← `pages/project/[ref]/database/schemas.tsx`
- [x] A `routes/project/$ref/database/extensions.tsx` ← `pages/project/[ref]/database/extensions.tsx`
- [x] A `routes/project/$ref/database/functions.tsx` ← `pages/project/[ref]/database/functions.tsx`
- [x] A `routes/project/$ref/database/indexes.tsx` ← `pages/project/[ref]/database/indexes.tsx`
- [x] A `routes/project/$ref/database/migrations.tsx` ← `pages/project/[ref]/database/migrations.tsx`
- [x] A `routes/project/$ref/database/policies.tsx` ← `pages/project/[ref]/database/policies.tsx`
- [x] A `routes/project/$ref/database/roles.tsx` ← `pages/project/[ref]/database/roles.tsx`
- [x] A `routes/project/$ref/database/settings.tsx` ← `pages/project/[ref]/database/settings.tsx`
- [x] A `routes/project/$ref/database/types.tsx` ← `pages/project/[ref]/database/types.tsx`
- [x] A `routes/project/$ref/database/column-privileges.tsx` ← `pages/project/[ref]/database/column-privileges.tsx`
- [x] A `routes/project/$ref/database/tables/index.tsx` ← `pages/project/[ref]/database/tables/index.tsx`
- [x] A `routes/project/$ref/database/tables/$id.tsx` ← `pages/project/[ref]/database/tables/[id].tsx`
- [x] A `routes/project/$ref/database/publications/index.tsx` ← `pages/project/[ref]/database/publications/index.tsx`
- [x] A `routes/project/$ref/database/publications/$id.tsx` ← `pages/project/[ref]/database/publications/[id].tsx`
- [x] A `routes/project/$ref/database/pipelines/index.tsx` ← `pages/project/[ref]/database/pipelines/index.tsx`
- [x] A `routes/project/$ref/database/pipelines/$pipelineId.tsx` ← `pages/project/[ref]/database/pipelines/[pipelineId].tsx`
- [x] A `routes/project/$ref/database/replication/replica/$replicaId.tsx` ← `pages/project/[ref]/database/replication/replica/[replicaId].tsx` (redirects to Infrastructure)
- [x] A `routes/project/$ref/database/triggers/index.tsx` ← `pages/project/[ref]/database/triggers/index.tsx`
- [x] A `routes/project/$ref/database/triggers/data.tsx` ← `pages/project/[ref]/database/triggers/data.tsx` (sub-shell at `database/triggers.tsx` provides PageLayout + nav, parent shell provides DatabaseLayout)
- [x] A `routes/project/$ref/database/triggers/event.tsx` ← `pages/project/[ref]/database/triggers/event.tsx` (same as data)
- [x] A `routes/project/$ref/database/backups/pitr.tsx` ← `pages/project/[ref]/database/backups/pitr.tsx`
- [x] A `routes/project/$ref/database/backups/restore-to-new-project.tsx` ← `pages/project/[ref]/database/backups/restore-to-new-project.tsx`
- [x] A `routes/project/$ref/database/backups/scheduled.tsx` ← `pages/project/[ref]/database/backups/scheduled.tsx`
### Project shell — `/auth/*`
- [x] A `routes/project/$ref/auth/overview.tsx` ← `pages/project/[ref]/auth/overview.tsx`
- [x] A `routes/project/$ref/auth/users.tsx` ← `pages/project/[ref]/auth/users.tsx`
- [x] A `routes/project/$ref/auth/providers.tsx` ← `pages/project/[ref]/auth/providers.tsx` (sets `skipAuthLayout: true`, wraps in `AuthProvidersLayout` directly)
- [x] A `routes/project/$ref/auth/mfa.tsx` ← `pages/project/[ref]/auth/mfa.tsx`
- [x] A `routes/project/$ref/auth/hooks.tsx` ← `pages/project/[ref]/auth/hooks.tsx`
- [x] A `routes/project/$ref/auth/smtp.tsx` ← `pages/project/[ref]/auth/smtp.tsx` (sets `skipAuthLayout: true`, wraps in `AuthEmailsLayout` directly)
- [x] A `routes/project/$ref/auth/sessions.tsx` ← `pages/project/[ref]/auth/sessions.tsx`
- [x] A `routes/project/$ref/auth/passkeys.tsx` ← `pages/project/[ref]/auth/passkeys.tsx`
- [x] A `routes/project/$ref/auth/performance.tsx` ← `pages/project/[ref]/auth/performance.tsx`
- [x] A `routes/project/$ref/auth/protection.tsx` ← `pages/project/[ref]/auth/protection.tsx`
- [x] A `routes/project/$ref/auth/rate-limits.tsx` ← `pages/project/[ref]/auth/rate-limits.tsx`
- [x] A `routes/project/$ref/auth/third-party.tsx` ← `pages/project/[ref]/auth/third-party.tsx` (sets `skipAuthLayout: true` — page body inlines `` which already wraps ``)
- [x] A `routes/project/$ref/auth/oauth-apps.tsx` ← `pages/project/[ref]/auth/oauth-apps.tsx`
- [x] A `routes/project/$ref/auth/oauth-server.tsx` ← `pages/project/[ref]/auth/oauth-server.tsx`
- [x] A `routes/project/$ref/auth/url-configuration.tsx` ← `pages/project/[ref]/auth/url-configuration.tsx`
- [x] A `routes/project/$ref/auth/audit-logs.tsx` ← `pages/project/[ref]/auth/audit-logs.tsx`
- [x] A `routes/project/$ref/auth/templates/index.tsx` ← `pages/project/[ref]/auth/templates/index.tsx` (sets `skipAuthLayout: true`, wraps in `AuthEmailsLayout` directly)
- [x] A `routes/project/$ref/auth/templates/$templateId.tsx` ← `pages/project/[ref]/auth/templates/[templateId].tsx` (`authLayoutTitle: 'Emails'` — page uses plain `AuthLayout`, not `AuthEmailsLayout`)
### Project shell — `/storage/*`
- [x] A `routes/project/$ref/storage/s3.tsx` ← `pages/project/[ref]/storage/s3.tsx`
- [x] A `routes/project/$ref/storage/files/index.tsx` ← `pages/project/[ref]/storage/files/index.tsx`
- [x] A `routes/project/$ref/storage/files/policies.tsx` ← `pages/project/[ref]/storage/files/policies.tsx`
- [x] A `routes/project/$ref/storage/files/settings.tsx` ← `pages/project/[ref]/storage/files/settings.tsx`
- [x] A `routes/project/$ref/storage/files/buckets/$bucketId.tsx` ← `pages/project/[ref]/storage/files/buckets/[bucketId].tsx` (sets `skipStorageBucketsLayout: true`)
- [x] A `routes/project/$ref/storage/analytics/index.tsx` ← `pages/project/[ref]/storage/analytics/index.tsx`
- [x] A `routes/project/$ref/storage/analytics/buckets/$bucketId.tsx` ← `pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx` (sets `skipStorageBucketsLayout: true`)
- [x] A `routes/project/$ref/storage/vectors/index.tsx` ← `pages/project/[ref]/storage/vectors/index.tsx`
- [x] A `routes/project/$ref/storage/vectors/buckets/$bucketId.tsx` ← `pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx` (sets `skipStorageBucketsLayout: true`)
### Project shell — `/realtime/*`
- [x] A `routes/project/$ref/realtime/inspector.tsx` ← `pages/project/[ref]/realtime/inspector.tsx`
- [x] A `routes/project/$ref/realtime/policies.tsx` ← `pages/project/[ref]/realtime/policies.tsx`
- [x] A `routes/project/$ref/realtime/settings.tsx` ← `pages/project/[ref]/realtime/settings.tsx`
### Project shell — `/compute/*`
- [x] A `routes/project/$ref/compute/index.tsx` ← `pages/project/[ref]/compute/index.tsx`
- [x] A `routes/project/$ref/compute/$name.tsx` ← `pages/project/[ref]/compute/[name].tsx`
- [x] A `routes/project/$ref/compute/secrets.tsx` ← `pages/project/[ref]/compute/secrets.tsx`
### Project shell — `/functions/*`
- [x] A `routes/project/$ref/functions/index.tsx` ← `pages/project/[ref]/functions/index.tsx` (route wraps in exported `EdgeFunctionsIndexPageWrapper` for the inline PageHeader + actions)
- [x] A `routes/project/$ref/functions/new.tsx` ← `pages/project/[ref]/functions/new.tsx`
- [x] A `routes/project/$ref/functions/secrets.tsx` ← `pages/project/[ref]/functions/secrets.tsx` (route wraps in exported `SecretsPageWrapper`)
- [x] A `routes/project/$ref/functions/$functionSlug/index.tsx` ← `pages/project/[ref]/functions/[functionSlug]/index.tsx`
- [x] A `routes/project/$ref/functions/$functionSlug/code.tsx` ← `pages/project/[ref]/functions/[functionSlug]/code.tsx`
- [x] A `routes/project/$ref/functions/$functionSlug/details.tsx` ← `pages/project/[ref]/functions/[functionSlug]/details.tsx`
- [x] A `routes/project/$ref/functions/$functionSlug/invocations.tsx` ← `pages/project/[ref]/functions/[functionSlug]/invocations.tsx`
- [x] A `routes/project/$ref/functions/$functionSlug/logs.tsx` ← `pages/project/[ref]/functions/[functionSlug]/logs.tsx`
### Project shell — `/branches/*`
- [x] A `routes/project/$ref/branches/index.tsx` ← `pages/project/[ref]/branches/index.tsx` (route wraps in exported `BranchesPageWrapper` to preserve the page's `PageLayout` + Create-branch action)
- [x] A `routes/project/$ref/branches/merge-requests.tsx` ← `pages/project/[ref]/branches/merge-requests.tsx` (route wraps in exported `MergeRequestsPageWrapper`)
### Project shell — `/logs/*`
- [x] A `routes/project/$ref/logs/index.tsx` ← `pages/project/[ref]/logs/index.tsx` (sets `skipLogsLayout: true`; page handles its own ProjectLayout; DefaultLayout moved to page's `getLayout` so Next still wraps it)
- [x] A `routes/project/$ref/logs/auth-logs.tsx` ← `pages/project/[ref]/logs/auth-logs.tsx`
- [x] A `routes/project/$ref/logs/cron-logs.tsx` ← `pages/project/[ref]/logs/cron-logs.tsx`
- [x] A `routes/project/$ref/logs/dedicated-pooler-logs.tsx` ← `pages/project/[ref]/logs/dedicated-pooler-logs.tsx`
- [x] A `routes/project/$ref/logs/edge-functions-logs.tsx` ← `pages/project/[ref]/logs/edge-functions-logs.tsx`
- [x] A `routes/project/$ref/logs/edge-logs.tsx` ← `pages/project/[ref]/logs/edge-logs.tsx`
- [x] A `routes/project/$ref/logs/multigres-logs.tsx` ← `pages/project/[ref]/logs/multigres-logs.tsx`
- [x] A `routes/project/$ref/logs/pg-upgrade-logs.tsx` ← `pages/project/[ref]/logs/pg-upgrade-logs.tsx`
- [x] A `routes/project/$ref/logs/pgcron-logs.tsx` ← `pages/project/[ref]/logs/pgcron-logs.tsx`
- [x] A `routes/project/$ref/logs/pooler-logs.tsx` ← `pages/project/[ref]/logs/pooler-logs.tsx`
- [x] A `routes/project/$ref/logs/postgres-logs.tsx` ← `pages/project/[ref]/logs/postgres-logs.tsx`
- [x] A `routes/project/$ref/logs/postgrest-logs.tsx` ← `pages/project/[ref]/logs/postgrest-logs.tsx`
- [x] A `routes/project/$ref/logs/realtime-logs.tsx` ← `pages/project/[ref]/logs/realtime-logs.tsx`
- [x] A `routes/project/$ref/logs/replication-logs.tsx` ← `pages/project/[ref]/logs/replication-logs.tsx`
- [x] A `routes/project/$ref/logs/storage-logs.tsx` ← `pages/project/[ref]/logs/storage-logs.tsx`
- [x] A `routes/project/$ref/logs/explorer/index.tsx` ← `pages/project/[ref]/logs/explorer/index.tsx`
- [x] A `routes/project/$ref/logs/explorer/recent.tsx` ← `pages/project/[ref]/logs/explorer/recent.tsx`
- [x] A `routes/project/$ref/logs/explorer/saved.tsx` ← `pages/project/[ref]/logs/explorer/saved.tsx`
- [x] A `routes/project/$ref/logs/explorer/templates.tsx` ← `pages/project/[ref]/logs/explorer/templates.tsx`
### Project shell — `/observability/*`
- [x] A `routes/project/$ref/observability/index.tsx` ← `pages/project/[ref]/observability/index.tsx`
- [x] A `routes/project/$ref/observability/$id.tsx` ← `pages/project/[ref]/observability/[id].tsx`
- [x] A `routes/project/$ref/observability/auth.tsx` ← `pages/project/[ref]/observability/auth.tsx`
- [x] A `routes/project/$ref/observability/database.tsx` ← `pages/project/[ref]/observability/database.tsx`
- [x] A `routes/project/$ref/observability/api-overview.tsx` ← `pages/project/[ref]/observability/api-overview.tsx`
- [x] A `routes/project/$ref/observability/connections.tsx` ← `pages/project/[ref]/observability/connections.tsx`
- [x] A `routes/project/$ref/observability/edge-functions.tsx` ← `pages/project/[ref]/observability/edge-functions.tsx`
- [x] A `routes/project/$ref/observability/postgrest.tsx` ← `pages/project/[ref]/observability/postgrest.tsx`
- [x] A `routes/project/$ref/observability/query-insights.tsx` ← `pages/project/[ref]/observability/query-insights.tsx`
- [x] A `routes/project/$ref/observability/query-performance.tsx` ← `pages/project/[ref]/observability/query-performance.tsx`
- [x] A `routes/project/$ref/observability/realtime.tsx` ← `pages/project/[ref]/observability/realtime.tsx`
- [x] A `routes/project/$ref/observability/storage.tsx` ← `pages/project/[ref]/observability/storage.tsx`
### Project shell — `/advisors/*`
- [x] A `routes/project/$ref/advisors/health.tsx` ← `pages/project/[ref]/advisors/health.tsx`
- [x] A `routes/project/$ref/advisors/performance.tsx` ← `pages/project/[ref]/advisors/performance.tsx`
- [x] A `routes/project/$ref/advisors/security.tsx` ← `pages/project/[ref]/advisors/security.tsx`
- [x] A `routes/project/$ref/advisors/rules/performance.tsx` ← `pages/project/[ref]/advisors/rules/performance.tsx`
- [x] A `routes/project/$ref/advisors/rules/security.tsx` ← `pages/project/[ref]/advisors/rules/security.tsx`
### Project shell — `/settings/*`
- [x] A `routes/project/$ref/settings/general.tsx` ← `pages/project/[ref]/settings/general.tsx`
- [x] A `routes/project/$ref/settings/addons.tsx` ← `pages/project/[ref]/settings/addons.tsx`
- [x] A `routes/project/$ref/settings/api.tsx` ← `pages/project/[ref]/settings/api.tsx` (sets `skipSettingsLayout: true` — page is a useEffect redirect)
- [x] A `routes/project/$ref/settings/dashboard.tsx` ← `pages/project/[ref]/settings/dashboard.tsx`
- [x] A `routes/project/$ref/settings/code-configuration.tsx` ← `pages/project/[ref]/settings/code-configuration.tsx`
- [x] A `routes/project/$ref/settings/infrastructure/index.tsx` ← `pages/project/[ref]/settings/infrastructure.tsx`
- [x] A `routes/project/$ref/settings/infrastructure/replica/$replicaId.tsx` ← `pages/project/[ref]/settings/infrastructure/replica/[replicaId].tsx`
- [x] A `routes/project/$ref/settings/integrations.tsx` ← `pages/project/[ref]/settings/integrations.tsx`
- [x] A `routes/project/$ref/settings/log-drains.tsx` ← `pages/project/[ref]/settings/log-drains.tsx`
- [x] A `routes/project/$ref/settings/api-keys/index.tsx` ← `pages/project/[ref]/settings/api-keys/index.tsx` (under `api-keys.tsx` sub-shell with ApiKeysLayout)
- [x] A `routes/project/$ref/settings/api-keys/legacy.tsx` ← `pages/project/[ref]/settings/api-keys/legacy.tsx` (under `api-keys.tsx` sub-shell)
- [x] A `routes/project/$ref/settings/billing/usage.tsx` ← `pages/project/[ref]/settings/billing/usage.tsx`
- [x] A `routes/project/$ref/settings/jwt/index.tsx` ← `pages/project/[ref]/settings/jwt/index.tsx` (wraps in JWTKeysLayout inline)
- [x] A `routes/project/$ref/settings/jwt/legacy.tsx` ← `pages/project/[ref]/settings/jwt/legacy.tsx`
- [x] A `routes/project/$ref/settings/webhooks/index.tsx` ← `pages/project/[ref]/settings/webhooks/index.tsx`
- [x] A `routes/project/$ref/settings/webhooks/$endpointId.tsx` ← `pages/project/[ref]/settings/webhooks/[endpointId].tsx`
### Project shell — `/integrations/*`
- [x] `routes/project/$ref/integrations/index.tsx` ← `pages/project/[ref]/integrations/index.tsx`
- [x] `routes/project/$ref/integrations/$id/index.tsx` ← `pages/project/[ref]/integrations/[id]/index.tsx`
- [x] `routes/project/$ref/integrations/$id/$pageId/index.tsx` ← `pages/project/[ref]/integrations/[id]/[pageId]/index.tsx`
- [x] `routes/project/$ref/integrations/$id/$pageId/$childId/index.tsx` ← `pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx`
### Project shell — `/sql/*`
- [x] A `routes/project/$ref/sql/index.tsx` ← `pages/project/[ref]/sql/index.tsx`
- [x] A `routes/project/$ref/sql/$id.tsx` ← `pages/project/[ref]/sql/[id].tsx`
- [x] A `routes/project/$ref/sql/templates.tsx` ← `pages/project/[ref]/sql/templates.tsx`
- [x] A `routes/project/$ref/sql/examples.tsx` ← `pages/project/[ref]/sql/examples.tsx`
### Project shell — `/editor/*`
- [x] A `routes/project/$ref/editor/index.tsx` ← `pages/project/[ref]/editor/index.tsx`
- [x] A `routes/project/$ref/editor/$id.tsx` ← `pages/project/[ref]/editor/[id].tsx`
- [x] A `routes/project/$ref/editor/new.tsx` ← `pages/project/[ref]/editor/new.tsx`
### Project shell — `/explorer/*`
- [x] A `routes/project/$ref/explorer/index.tsx` ← `pages/project/[ref]/explorer/index.tsx`
- [x] A `routes/project/$ref/explorer/notebook/$id.tsx` ← `pages/project/[ref]/explorer/notebook/[id].tsx`
- [x] A `routes/project/$ref/explorer/chat/$id.tsx` ← `pages/project/[ref]/explorer/chat/[id].tsx`
- [x] A `routes/project/$ref/explorer/query/$id.tsx` ← `pages/project/[ref]/explorer/query/[id].tsx`
### Auth shell — `/sign-in`, `/sign-up`, etc.
- [x] A `routes/_auth/sign-in.tsx` ← `pages/sign-in.tsx`
- [x] A `routes/_auth/sign-up.tsx` ← `pages/sign-up.tsx`
- [x] A `routes/_auth/sign-in-sso.tsx` ← `pages/sign-in-sso.tsx`
- [x] A `routes/_auth/sign-in-partner.tsx` ← `pages/sign-in-partner.tsx`
- [x] A `routes/_auth/sign-in-mfa.tsx` ← `pages/sign-in-mfa.tsx` (page inlines SignInLayout)
- [x] A `routes/_auth/sign-in-recovery-code.tsx` ← `pages/sign-in-recovery-code.tsx` (page inlines SignInLayout)
- [x] A `routes/_auth/forgot-password.tsx` ← `pages/forgot-password.tsx`
- [x] A `routes/_auth/forgot-password-mfa.tsx` ← `pages/forgot-password-mfa.tsx` (page inlines ForgotPasswordLayout)
- [x] A `routes/_auth/reset-password.tsx` ← `pages/reset-password.tsx` (page default already withAuth-wrapped)
- [x] A `routes/_auth/cli/login.tsx` ← `pages/cli/login.tsx` (page inlines APIAuthorizationLayout, withAuth)
- [x] A `routes/_auth/partners/stripe/projects/login.tsx` ← `pages/partners/stripe/projects/login.tsx` (page inlines APIAuthorizationLayout, withAuth)
### Standalone (no shared shell)
- [x] B `routes/index.tsx` — redirect-only root route. Mirrors the Next.js `redirects()` rules in `next.config.ts`: platform sends users to `/org` (or `/new/new-project` when deep-linked with `?next=new-project`), self-hosted sends them to `/project/default`. **Follow-up:** the redirect targets currently use `href` (full reload) because they were on the Next side when this was written; switch to `to` now that all of them live in the TanStack tree.
- [x] A `routes/authorize.tsx` ← `pages/authorize.tsx` (APIAuthorizationLayout)
- [x] A `routes/redeem.tsx` ← `pages/redeem.tsx` (RedeemCreditsLayout)
- [x] A `routes/logout.tsx` ← `pages/logout.tsx`
- [x] A `routes/maintenance.tsx` ← `pages/maintenance.tsx`
- [x] A `routes/verify-email.tsx` ← `pages/verify-email.tsx`
- [x] A `routes/mcp/secrets.tsx` ← `pages/mcp/secrets.tsx` (MCP elicitation URL-mode handoff; page brings its own `InterstitialLayout`, so it stays outside `_app/`.)
### Error pages (handled at root)
- [x] A `__root.tsx` — wired `notFoundComponent` to `pages/404.tsx`
- [x] `__root.tsx` — wired `errorComponent` to `pages/500.tsx`. Mirrors the in-tree `react-error-boundary` Sentry capture (`scope.setTag('routerErrorComponent', true)`) so router-level errors (loader/component-render failures before the in-tree boundary mounts) still report. `pages/_error.jsx` stays load-bearing under Next but isn't reached at runtime under TanStack — it's the pages-router catch-all that has no TanStack equivalent.
---
## API routes
**Strategy — shim + re-export.** `compat/next/api.ts` exposes
`toWebHandler(nextHandler)` that adapts a `(req, res) => …` Next.js handler
into a TanStack Start Web-fetch handler. Each `routes/api/...` file imports
the default export from `pages/api/...`, wraps with `toWebHandler`, and
registers via `createFileRoute(...).server.handlers`. `apiWrapper` and
`apiAuthenticate` stay untouched — they run inside the shim, seeing a
NextApiRequest-shaped `req` and a proxy `res`.
**Path conventions** — `pages/api/foo/[bar]/baz.ts` → `routes/api/foo/$bar/baz.ts`;
`pages/api/foo/[[...slug]].ts` → `routes/api/foo/$.ts`.
**Shim coverage.** The proxy `req` / `res` cover both the buffered and
streaming patterns that pages-router handlers use:
- **Buffered responses** — `res.status`/`setHeader`/`json`/`send`/`write`/
`end` accumulate into a single `Response` body when the handler returns.
- **Streaming responses** — `res.writeHead(status, headers?)` (or
`res.flushHeaders()`) flips the proxy into streaming mode: a Web
`ReadableStream` opens, buffered chunks flush into it, subsequent
`res.write(chunk)` enqueues live, `res.end()` closes it. `finalize()`
returns the `Response` while the handler keeps pushing chunks. This is
what makes `result.pipeUIMessageStreamToResponse(res, …)` (AI SDK)
stream token-by-token to the browser.
- **Client abort** — Web `Request.signal` is plumbed through as
`req.on('close' | 'aborted', …)`. AI handlers that wire
`abortController.abort()` off those events keep working.
- **EventEmitter surface** — `req.on`/`once`/`off`/`emit` (events `close`
/ `aborted` are real; other names accepted but no-op). `res.on`/etc.
are no-op stubs so pipe helpers attaching `drain`/`close`/`error`
listeners don't crash.
- **Body parsing** — JSON and `application/x-www-form-urlencoded` parsed
to `req.body`; everything else is the raw text. Multipart inbound is
not implemented — no studio handler reads multipart in.
Two routes still bypass the shim because they're easier to write
Web-natively from scratch:
- `routes/api/v1/projects/$ref/functions/$slug/body.ts` — multipart
streaming OUT (artifact download). Builds the `Response` body as a
`ReadableStream`; each artifact file converts via
`Readable.toWeb(createReadStream(...))` and pulls chunk-by-chunk into
the stream.
- `routes/api/mcp/index.ts` — uses MCP SDK's
`WebStandardStreamableHTTPServerTransport` (`handleRequest(request)`
returns a `Response` directly).
- `pages/api/ai/docs.ts` was already edge-runtime / Web-Response native
— direct re-export, no shim involved.
Tracking below is coarse — each bullet is a `pages/api/**` subtree. Check off
once every file in the subtree has a `routes/api/**` counterpart. Expand into
per-file items only when a subtree has special cases.
- [x] `routes/api/get-ip-address.ts` — canary port (validates the shim)
- [x] `routes/api/**` — root-level simple endpoints (`check-cname`,
`cli-release-version`, `enabled-features-overrides`, `generate-attachment-url`,
`get-deployment-commit`, `get-utc-time`, `status-override`)
- [x] `routes/api/ai/**` — AI endpoints (`docs.ts` direct-ported as Web-native)
- [x] `routes/api/connect/**`
- [x] `routes/api/content/**`
- [x] `routes/api/edge-functions/**`
- [x] `routes/api/integrations/**`
- [x] `routes/api/platform/**` (60 files, scripted port)
- [x] `routes/api/v1/**` — except `body.ts` (streaming rewrite)
- [x] `routes/api/v1/projects/$ref/functions/$slug/body.ts` — Web-streams rewrite. Returns a `Response` whose body is a `ReadableStream`; each artifact file is converted via `Readable.toWeb(createReadStream(...))` and pulled chunk-by-chunk into the multipart stream. Skips the `apiWrapper` since `getFunctionsArtifactStore` already asserts self-hosted mode (the pages-router `withAuth` was a no-op outside `IS_PLATFORM`).
- [x] `routes/api/mcp/index.ts` — uses `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js`. Takes a Web `Request`, returns a `Response` directly — no shim needed. Query parsing pulled from `request.url`'s search params; headers passed through unchanged.
- [x] `routes/api/incident-banner.ts`, `routes/api/incident-status.ts`,
`routes/api/status-page.ts` — App Router routes under `app/api/**`
(already Web-native, direct re-export)
---
## Compat shim surface (`compat/next/`)
The Next compat shims stay alive as long as any `pages/...` file is
load-bearing. Listed here so the cleanup PR knows what to delete /
inline.
- `router.ts` — `useRouter()` for hook callers (TanStack `useRouter` +
`useLocation` + `useMatches` + `useParams` + `useSearch` glued
together), plus a `default` export (`SingletonRouter` shape) for the
one module-scope `import router from 'next/router'` consumer
(Support/DiscordCTACard) that reads `router.basePath` outside React.
`router.pathname` strips the trailing slash TanStack appends to index
routes (without it, `router.pathname.split('/')[3]` returns `''`
instead of `undefined` for index pages and the project sidebar's
active-route check breaks).
- `_router-events.ts` — adapts `router.events.on(event, handler)` onto
`router.subscribe(tsEvent, …)`. Forwards Next's `(url, { shallow })`
args. Maps `routeChangeStart` / `routeChangeComplete` /
`beforeHistoryChange` / `hashChangeStart` / `hashChangeComplete`.
**Known gap:** Next's throw-from-`routeChangeStart`-to-cancel pattern
isn't supportable — `subscribe` is fire-and-forget.
`usePreventNavigationOnUnsavedChanges` relies on it and needs
migrating to TanStack's `useBlocker` separately.
- `api.ts` — `toWebHandler(nextHandler)`. See **API routes → Shim
coverage** above.
- `link.tsx`, `navigation.ts`, `dynamic.tsx`, `image.tsx`,
`legacy/image.tsx`, `script.tsx`, `head.tsx`, `server.ts` — comprehensive
drop-in replacements for the `next/*` modules studio imports. All
bundled via `vite.config.ts`'s `nextCompat()` plugin (alias) +
`ssr.noExternal: [/^next(\/|$)/]` so the shims always win over the
real Next packages.
---
## Build / bundler workarounds
`vite.config.ts` carries two classes of build-time guard that exist
purely because of how Rolldown chunks our specific dependency graph.
They should be revisited (and ideally lifted) once the migration is
done.
### `manualChunks` pins
Pin shared library code into dedicated chunks so per-component chunks
can't import from a chunk that (transitively) imports them back —
chunk-level cycles surface in the browser as
`TypeError: is not a function` at module-load time.
- `class-variance-authority` — entry #1 in CIRCULAR_IMPORTS.md.
- `lucide-react` — keeps Lucide icons from being per-icon-split into
chunks that import `createLucideIcon` back from the `ui` chunk
(`folder-open-.js` was the canary).
- `react-vendor` (react + react-dom + scheduler + jsx-runtime) — pinned
before `lucide-react` so Rolldown doesn't suck React into the
lucide chunk for CJS interop and shift live-bindings across the rest
of the graph (`Alert-.js` was the canary).
All three are documented in `CIRCULAR_IMPORTS.md` — slated for a
follow-up structural fix in `packages/ui` so the pins can be lifted.
### `assertNoChunkCycles` build plugin
Vite plugin that runs Tarjan's SCC on the emitted chunk graph in
`generateBundle` and fails the build if any unknown chunk cycle exists.
The pre-existing CVA cycle is allowlisted by chunk basename
(`KNOWN_CHUNK_CYCLES` constant) so the build still passes; any **new**
cycle blocks the build with a message pointing at CIRCULAR_IMPORTS.md.
Keep this plugin even after migration — it's not a Next-related shim,
it's general protection against this entire class of bug. Just clear
the allowlist when the underlying cycle is gone.
### `@sentry/nextjs` → `@sentry/react` alias
`resolve.alias` in `vite.config.ts` rewrites the bare `@sentry/nextjs`
import to `compat/sentry-nextjs.ts`, which re-exports `@sentry/react`
(the same-version package `@sentry/nextjs` wraps on the client) plus
explicit stand-ins for the Next-only APIs (`captureRouterTransitionStart`,
`captureRequestError`, `withSentryConfig`).
Why: `@sentry/nextjs`'s client entry imports
`next/dist/shared/lib/constants`, whose module scope evaluates
`...(process?.features?.typescript ? ['next.config.mts'] : [])`.
Optional chaining does **not** guard an undeclared `process` identifier,
so every built client chunk containing it (table editor was the canary)
threw `ReferenceError: process is not defined` at module load. Dev was
unaffected (dev pipeline shims `process`), so it only surfaced in the
production/test build.
The alias also made the previous `@sentry/nextjs` SSR workarounds
(`ssr.noExternal` entry + `ssr.optimizeDeps.include`) obsolete — the id
is rewritten before SSR resolution, and `@sentry/react` ships real ESM.
App source keeps importing `@sentry/nextjs` so the Next build
(`build:next`) is untouched; drop the alias + shim together with the
Next build when the migration is done (switch imports to
`@sentry/react` directly).
### GraphiQL Monaco workers: `setup-workers/webpack` → `setup-workers/vite`
App source (`GraphiQLTab.tsx`) imports `graphiql/setup-workers/webpack`,
which registers `MonacoEnvironment.getWorker` using
`new Worker(new URL('monaco-editor/...', import.meta.url))` — the URL form
webpack/turbopack rewrites at build time. Vite doesn't rewrite bare module
specifiers inside `new URL(..., import.meta.url)`, so under the TanStack
build the worker URLs 404'd and Monaco fell back to running the `json`,
`editorWorkerService` and `graphql` workers on the main thread ("Could not
create web worker(s). Falling back to loading web worker code in main
thread" in the console).
The `graphiqlViteWorkers` plugin in `vite.config.ts` resolves that import
to graphiql's own `setup-workers/vite` variant (same three workers via
Vite `?worker` imports) in client builds; SSR resolution is untouched. The
import specifier stays `.../webpack` in app source so the Next build keeps
working. The whole setup-workers chain is also in `optimizeDeps.exclude` —
the Rolldown dep optimizer can't load `?worker` ids
(`UNLOADABLE_DEPENDENCY`), so the modules go through the normal transform
pipeline where Vite's built-in worker plugin handles them. Drop the plugin
and the exclude, and switch the import to `graphiql/setup-workers/vite`,
when the Next build goes away.
### Raw-text imports: `*.md` + `public/deno/*.d.ts` (`rawTextLoader`)
Next's raw-loader rules (next.config.ts `turbopack.rules`) serve `*.md`
files and the Deno typings `public/deno/edge-runtime.d.ts` /
`public/deno/lib.deno.d.ts` as JS modules whose default export is the
file's text. The `rawTextLoader` plugin in `vite.config.ts` mirrors that
for the Vite pipeline:
- `*.md` — plain `transform` (used by
`static-data/integrations/*/overview.md` via the literal-import registry
in `static-data/integrations/overviews.ts`).
- The two Deno `.d.ts` files (used by `components/ui/AIEditor` as Monaco
extra libs for edge-function editors) — an exact-specifier allowlist
resolved to `\0`-virtual ids and served from a `load` hook. They can't go
through `transform`: Rolldown's native dep scanner skips JS plugin hooks
and hard-fails parsing TS _declaration_ syntax (`get stdin(): ...;`) as
runtime TS, which killed dependency pre-bundling wholesale. The previous
`/* @vite-ignore */` hack kept the scanner away but also meant the
imports failed at runtime, silently dropping Deno type hints in the
TanStack build. Do NOT widen the allowlist to `*.d.ts` — hijacking
declaration-file resolution globally would corrupt packages that ship
`.d.ts` next to their JS. The `as string` casts on the import specifiers
in `AIEditor/index.tsx` keep tsc from resolving the `.d.ts` files as
declaration files (TS2846) while erasing to plain literals both bundlers
statically analyze.
### Other build-side migration changes
- `pnpm-workspace.yaml` catalog now includes `@tanstack/react-router`,
`@tanstack/react-start`, `@tanstack/react-table` so studio and
library stay aligned. `react-query` is **not** in the catalog yet
— three consumers (studio, docs, library) sit on different 5.x
ranges and unifying them is a separate decision.
- `NODE_OPTIONS=--max-old-space-size=8192` is set on the studio
`dev` script — Vite's Rolldown-RC frontend hits the default 4 GB
ceiling when chewing through studio's module graph in watch mode.
---
## Deferred / revisit
- ~~`pages/org/_/[[...routeSlug]].tsx`~~ landed as `routes/org.[_].tsx` + `routes/org.[_].$.tsx`. **Naming delta:** path-as-filename form (not `routes/org/[_]/index.tsx`) because the index-file form trips a router-generator bug at `getRouteNodes.js:132` — when an `index.tsx` has a bracket-escaped _parent_ segment, `originalRoutePath` gets wiped wholesale and the escape info is lost, so `_` gets stripped as pathless. The path-as-filename form keeps the last segment non-index and avoids the bug branch entirely. Next page accepts either Next-style `routeSlug` (string[]) or TanStack-style `_splat` (string) and normalises to the array shape.
- ~~`pages/project/_/[[...routeSlug]].tsx`~~ landed as `routes/project.[_].tsx` + `routes/project.[_].$.tsx`. Same naming-delta rationale as the org catch-alls above.
### Cleanup checklist (after every `pages/...` file is gone)
- Switch `routes/index.tsx` redirects from `href` to `to` — all targets
now live in the TanStack tree.
- Migrate `usePreventNavigationOnUnsavedChanges` from `router.events.on('routeChangeStart', …)` (throw-to-cancel pattern) to TanStack's `useBlocker`.
- Drop the `_splat` / `routeSlug` normalisation block from
`pages/org/_/[[...routeSlug]].tsx` + `pages/project/_/[[...routeSlug]].tsx` (only there to keep both runtimes mounting the same body).
- Remove `RouteValidationWrapper` + `next/router` compat shim usage from `__root.tsx`.
- Remove `compat/next/` directory entirely once no `next/*` import remains in workspace source.
- Lift `manualChunks` pins (`class-variance-authority`, `lucide-react`, `react-vendor`) once the structural fix in `packages/ui` lands — see CIRCULAR_IMPORTS.md. Keep `assertNoChunkCycles`; just clear `KNOWN_CHUNK_CYCLES`.
- Delete `pages/_app.tsx`, `pages/_document.tsx`, `pages/_error.jsx`, `pages/500.tsx`, `pages/404.tsx` (Next-only catch-alls; TanStack equivalents on `__root.tsx`).
- Drop the `dev:next` / `build:next` / `start:next` scripts from `apps/studio/package.json` once we're committed to TanStack.
- Remove the `apps/studio/pages/**` `path_instructions` guardrail entry from `.coderabbit.yaml` (added in FE-3423; remove it as part of this FE-3106 cleanup) — it's only useful while both runtimes coexist.
- Remove the "TanStack Start migration" section from `apps/studio/AGENTS.md` — it only applies while both runtimes coexist.
- Delete this file.