Skip to main content
This page extends the Start bridge v0 contract with two additive pieces: an RSC-based generic page adapter, so a converted app needs no hand-written page models, and a codemod that converts a Next satellite with no hand edits. Production is unchanged; conversion produces a staging candidate only.

Generic page adapter (RSC mode)

Next pages are async server components with 'use client' islands. Start’s RSC environment (@tanstack/react-start RSC mode with @vitejs/plugin-rsc) runs that code with the same boundaries Next enforces, so retained page source renders unchanged. Per request, a Start server function in the app (generated src/start-bridge/page-route.tsx) calls resolveBridgePage from start/page-render:
  1. Resolve the location the app sees (start/page-location): on SSR the request the handler forwarded after proxy.ts, so rewrites apply while the browser keeps its URL; on client navigation the app proxy runs for the target, so the same URL renders the same page, a proxy redirect becomes a Start redirect and any other terminal response reloads the document. Match that app-relative path (mount stripped; a leading locale segment addresses a manifest with a stripped [locale]) against the generated page manifest. No match answers 404.
  2. Load the page and its layout/template chain from the generated page-modules.bridge.gen.ts (lazy imports keyed by the manifest file).
  3. Pass Next’s props: params and searchParams as Promises; repeated query keys become arrays. A stripped [locale] segment resolves from the middleware x-next-intl-locale header, then NEXT_LOCALE, then the default.
  4. Merge metadata/generateMetadata and viewport like Next (shallow merge, nearest title.template) and convert them to head() data.
  5. Lift the root layout’s <html>/<body> attributes into the Start shell (createBridgeRootRoute). Server-component wrappers and the RSC CSS fragment around the root layout are expanded first; client references are never called.
  6. Await async pages and layouts before streaming, so notFound() and redirect() in their bodies answer 404 and redirect. Sync server components stay elements because they may call hooks.
  7. Return the tree through renderServerComponent; the catch-all and index routes render it, on SSR and on client navigation alike.

Route handlers run in the rsc environment

plugin-rsc rejects server-only in every environment except rsc, and the Worker entry is bundled in ssr. The generated vite.config.mts registers the API registry as the bridge-api rsc entry (rscEntries), and cloudflare/worker.ts loads it with import.meta.viteRsc.loadModule('rsc', 'bridge-api') before building the handler. Route modules (and the server-only packages they import) are compiled once, in rsc; the request store is global, so next/headers, cookies and the mount see the same request. Never alias or disable plugin-rsc’s import validation to make a build pass. redirect() from next/navigation keeps an absolute URL absolute, as in Next. Start’s router reduces a same-origin absolute href to its path, and on the apex Web’s https://tuturuuu.com/login is same-origin with tuturuuu.com/<app>, so the bridge marks the target for its response finalization (which otherwise adds the mount) and sets reloadDocument for client navigation. Without it, a mounted app’s login redirect loops into its own /<app>/login. Fonts from next/font resolve to inert objects; load font CSS through the app stylesheet. A page that needs Next behaviour the adapter lacks (parallel or intercepting routes) stays a hand-written page via owned/ignored in start-bridge.routes.json.

Converting an app

The dry run reads only. --apply writes the same change set and installs the pinned toolchain through bun add --exact in the app workspace; dependencies are never edited into package.json by hand. Re-running an applied app is a no-op. Every owned file carries a “Generated by the start-bridge codemod” marker; an unmarked hand-written file is a conflict that blocks --apply unless --adopt is passed. Codegen errors (an unregistered route.*/page.*), a missing src/proxy.ts and, with --hold, a missing Vercel publisher also block. Owned files and the app’s JSON edits are formatted with the repository’s Biome. The codemod writes or edits:
  • start-bridge.routes.json (hand-maintained owned/ignored entries win on re-runs) and the bridge codegen registries, plus page-modules.bridge.gen.ts and public-assets.bridge.gen.ts.
  • vite.config.mts (preset in RSC mode), void.config.ts (the held tuturuuu-<app>-vite-staging Worker with TUTURUUU_NOINDEX=1), cloudflare/worker.ts (mounts: <app>.tuturuuu.com root and the tuturuuu.com/<app> mount), tsconfig.cloudflare.json.
  • src/start.ts (bridgeServerFnFetch), src/router.tsx (routerBasepath(), ssr: { nonce: requestNonce() }), the root/index/catch-all routes and routeTree.gen.ts. The Worker does not enforce csp by default: each app adds createBridgeHandler({ csp }) with a policy that fits its scripts before it mounts on the apex.
  • Package scripts build:cloudflare, dev:cloudflare, preview:cloudflare (--configLoader runner), routes:check and type-check:cloudflare. build stays next build, so the current Vercel deployment is unchanged.
  • .env.example moves to env.example: Void accepts only .env.
  • The biome.json override that keeps every generated routeTree.gen.ts out of Biome (formatter, linter and assist off), once for the repository. TanStack’s generator rewrites the file in its own format on each build, and bun check:changed passes changed files to Biome explicitly.
  • With --hold only, the held record: tuturuuu.ci.ts productionPublicationPolicy, the protected surface in scripts/ci/production-publication-policy.ts and its regression’s app list, and the production-policy gate in vercel-production-<app>.yaml (as for Shortener).
  • tsconfig.json excludes the Start-only sources, so the existing Next type-check is unchanged; type-check:cloudflare checks them against tsconfig.cloudflare.json.
A held record also holds that app’s vercel-production-<app>.yaml, so the current Vercel production deployment freezes until the cutover admits the app. Conversion PRs therefore run without --hold; the app’s cutover-prep PR adds the hold and merges just before the flip.

Worker options

start-bridge.config.ts at the app root is app-owned: the codemod creates it once (defineBridgeAppConfig({})) and never updates or replaces it. The generated cloudflare/worker.ts passes its options on, so an app changes Worker behaviour without editing generated files: API paths the app owns but forwards (Storefront’s Inventory APIs) are listed client-safely in start-bridge.routes.json as "api": { "forwarded": ["/api/v1/inventory/[...path]"] }. The codegen merges them into appApiPatterns, so the shared app-base resolver mounts them like local routes, and the Worker, finding no local handler, forwards them through apiOrigin. start-bridge.config.ts holds server code and is never imported by the browser.

Route handlers load lazily

The generated API registry (api.lazy) imports each route on its first request (lazyRouteModule), with the route’s HTTP methods read statically from its source so OPTIONS and 405 need no import. One route’s dependencies (for example a Node-only SDK chain) therefore cannot fail the Worker at startup; a route that re-exports with export * stays eager. In the rsc build, CommonJS require() of Node built-ins becomes an ESM import served by nodejs_compat, and .wasm/.bin imports become Wrangler CompiledWasm/Data modules (as next/og’s Workers port needs). An app whose Worker code runs in native SSR (rsc: { enabled: false }, as Calendar does) opts its ssr environment into the same conversion by adding startBridgeNodeBuiltinsPlugin() from @tuturuuu/start-bridge/vite/rsc to its Vite plugins; rsc apps need nothing. A route that needs a Node API workerd does not provide still fails when it is hit; serve it from Web (centralFirst) or port it.

Native CI

When the repository has the reusable .github/workflows/native-start-app.yaml (#6753), the codemod also writes the app’s qualification CI, modelled on Shortener:
  • .github/workflows/<app>-cloudflare.yaml: a thin workflow_call caller with path filters for the app, its transitive workspace dependencies, the template and the shared native CI scripts, and Shortener’s inputs. An app without a test script runs its Vitest suite with --passWithNoTests. Registered in tuturuuu.ci.ts.
  • scripts/ci/<app>-native-artifact.py: the app’s Worker identity and public files over scripts/ci/apps-native-artifact.py.
  • cloudflare/native-built-worker.runtime.mjs (test:runtime, run with node --test after the build; not a *.test.* file, so source test runs never pick it up): a generic emitted-Worker smoke run on the sealed artifact, with all egress denied: Worker identity, public-file parity, build identity when the app has /api/build-info, and no server error on the root, the tuturuuu.com/<app> mount or an unknown path. App-specific acceptance goes in further cloudflare/*.runtime.mjs files, which test:runtime (node --test cloudflare/*.runtime.mjs) runs with it.
There is no publisher: production stays with the held record and the cutover PR.

Generator inputs

The codemod reads app-specific behaviour from the app’s existing configuration, so apps regenerate instead of forking generated files: The preset renders @tuturuuu/vercel’s Analytics and Speed Insights as nothing, because Workers serve no /_vercel/* scripts; an app with an explicit shims list opts in with inertVercel: true. Each environment bundle (browser, SSR, rsc) carries its own internal-api module, so the registration is imported by src/router.tsx, the page route and the rsc API entry (src/start-bridge/api-entry.ts).

Coverage report

The report classifies each page through its layouts and local import graph. A page is generic unless it reaches an unshimmed framework specifier or a parallel/intercepting segment. The shim list follows the bridge preset; 'use server' modules and next/og are features, not blockers.

Verification

Codemod regressions run with node scripts/start-bridge-codemod/<file>.test.mjs on disposable fixture repositories; edits to repository files are tested against the real anchors. Each converted app still needs exact-commit CI: unit, types, lint, the real vp build, the sealed artifact and the emitted-runtime and browser acceptance.