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:
- Resolve the location the app sees (
start/page-location): on SSR the request the handler forwarded afterproxy.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. - Load the page and its layout/template chain from the generated
page-modules.bridge.gen.ts(lazy imports keyed by the manifestfile). - Pass Next’s props:
paramsandsearchParamsas Promises; repeated query keys become arrays. A stripped[locale]segment resolves from the middlewarex-next-intl-localeheader, thenNEXT_LOCALE, then the default. - Merge
metadata/generateMetadataandviewportlike Next (shallow merge, nearesttitle.template) and convert them tohead()data. - 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. - Await async pages and layouts before streaming, so
notFound()andredirect()in their bodies answer 404 and redirect. Sync server components stay elements because they may call hooks. - 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 rejectsserver-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
--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-maintainedowned/ignoredentries win on re-runs) and the bridge codegen registries, pluspage-modules.bridge.gen.tsandpublic-assets.bridge.gen.ts.vite.config.mts(preset in RSC mode),void.config.ts(the heldtuturuuu-<app>-vite-stagingWorker withTUTURUUU_NOINDEX=1),cloudflare/worker.ts(mounts:<app>.tuturuuu.comroot and thetuturuuu.com/<app>mount),tsconfig.cloudflare.json.src/start.ts(bridgeServerFnFetch),src/router.tsx(routerBasepath(),ssr: { nonce: requestNonce() }), the root/index/catch-all routes androuteTree.gen.ts. The Worker does not enforcecspby default: each app addscreateBridgeHandler({ 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:checkandtype-check:cloudflare.buildstaysnext build, so the current Vercel deployment is unchanged. .env.examplemoves toenv.example: Void accepts only.env.- The
biome.jsonoverride that keeps every generatedrouteTree.gen.tsout of Biome (formatter, linter and assist off), once for the repository. TanStack’s generator rewrites the file in its own format on each build, andbun check:changedpasses changed files to Biome explicitly. - With
--holdonly, the held record:tuturuuu.ci.tsproductionPublicationPolicy, the protected surface inscripts/ci/production-publication-policy.tsand its regression’s app list, and theproduction-policygate invercel-production-<app>.yaml(as for Shortener). tsconfig.jsonexcludes the Start-only sources, so the existing Nexttype-checkis unchanged;type-check:cloudflarechecks them againsttsconfig.cloudflare.json.
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 thinworkflow_callcaller 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 atestscript runs its Vitest suite with--passWithNoTests. Registered intuturuuu.ci.ts.scripts/ci/<app>-native-artifact.py: the app’s Worker identity and public files overscripts/ci/apps-native-artifact.py.cloudflare/native-built-worker.runtime.mjs(test:runtime, run withnode --testafter 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, thetuturuuu.com/<app>mount or an unknown path. App-specific acceptance goes in furthercloudflare/*.runtime.mjsfiles, whichtest:runtime(node --test cloudflare/*.runtime.mjs) runs with it.
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 withnode 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.