Repository navigation
[Epic] Migrate Next.js 15 → TanStack Start (SSR) on Cloudflare Workers #754
Description
Track B of #744. Migrate the framework Next.js 15 App Router → TanStack Start (SSR), deployed on Cloudflare Workers. Keep the Supabase + Neynar data layer (data-layer evaluation is separate: #742).
🔄 Approach update — in-place migration, React 19 first
The migration runs IN-PLACE in this repo — there is no separate app/folder. An earlier exploration used a parallel web/ directory to sidestep React 18-vs-19; that's been dropped. A separate tree would force duplicating all of src/ (components, the 6 stores, the 25 pages) in Phases 2–3 — the opposite of the goal.
Prerequisite — React 19 (PR #759, in flight): Next 15 supports React 19, so upgrading the whole repo to React 19 lets Next and TanStack Start share one dependency tree (no second app needed). Validated clean: tsc --noEmit 0 errors, next build ✓ (all 25 pages + 31 routes), 128 tests pass, single deduped react@19.2.7 copy (the @mod-protocol/react/react-editor ^18 peer pins are handled via pnpm overrides). No codemods needed — the codebase has none of the React-19 breaking patterns (ReactDOM.render/findDOMNode/string-refs/useFormState/defaultProps/propTypes/argless useRef).
Sequencing:
- Merge Upgrade React 18 → 19 #759 (React 19) — the prerequisite.
- Build the Phase 1 foundation in-place: TanStack
vite.config.ts+wrangler.jsonc+src/routes/+ providers/auth/next-font→@fontsource+@font-face/next-imageprimitives at the repo root, reusing the existingsrc/globals.css/ fonts /tailwind.config.js(no copies). Next keeps running alongside until Phase 4. - Phases 2–4 as below.
The throwaway
web/-folder Phase-1 spike lives on branchfeat/tanstack-start-phase1(reference only — its auth-cookie wiring, font replacement, andnext/imageprimitive port directly to the in-place layout). It will not be merged.
Why (drivers)
- Cloudflare hosting for high reproducibility / forkability (primary subgoal).
- Escape Next/Vercel coupling (control / devex / lock-in — framework half of [Discovery] Evaluate a rawer, pooled Postgres data layer vs. the full Supabase stack #742).
- Build speed (Vite), React 19, better testing / easier OODA loop for agents.
- Framework-native loaders +
preload="intent"— absorbs [Responsiveness] Prefetch-on-intent for profile/cast navigation #737 and the prefetch half of [Responsiveness] Instant feed switching (keepPreviousData + adjacent prefetch) #736.
Forkability bar (scope)
- Target:
wrangler deploy+ a secrets template (.dev.vars/wrangler.tomlexample) stands up a fork. - Assumes an already-migrated, working Supabase instance. Supabase bootstrap / seed data is out of scope.
Success criteria
- Deployed on Cloudflare Workers (Start via Nitro/Vite preset); fork →
wrangler deploy+ secrets → running. - React 19; faster local build/dev; green tests; INP targets met ([Measurement] INP / action-to-paint instrumentation (web-vitals onINP) #743 ✅); typed routes + intent-prefetch working.
Phases
- Phase 0 — Spike (go/no-go gate) — ✅ complete (GO): discovery (below) + thinnest-spike PoC + Phase 0.5 (WASM, auth-write) all validated on real workerd (see comments).
- Phase 1 — Foundation (in-place; requires React 19 Upgrade React 18 → 19 #759): at the repo root — TanStack
vite.config.ts/wrangler.jsonc, root route + app shell, auth, providers (TanStack Query, themes); replacenext/font+next/imagereusing the existingsrc/globals.css/ fonts /tailwind.config.js; secrets template. (See thefeat/tanstack-start-phase1reference branch.) - Phase 2 — Routes: port 25 pages to the typed route tree; loaders +
preload="intent"(absorbs [Responsiveness] Prefetch-on-intent for profile/cast navigation #737 + [Responsiveness] Instant feed switching (keepPreviousData + adjacent prefetch) #736 prefetch half). - Phase 3 — Server functions: migrate 31 API routes to Start server functions on Workers.
- Phase 4 — Cutover & cleanup: remove the ~106
next/*sites + Vercel config; document the forkable deploy; decommission Next.
Phase 0 discovery — feasibility audit ✅ (read-only spike, complete)
Verdict: GO
herocast is effectively a client SPA wearing App Router: only 1 of 25 pages is a real async server component (app/oauth/consent/page.tsx); the rest are 'use client'. No middleware.ts, no server-side service-role Supabase key, no CPU-heavy or edge routes, and 31 API routes that are nearly all thin I/O proxies to Neynar/Supabase/fetch. Migration cost is concentrated in tooling/config + mechanical volume, not architecture.
The 3 unknowns the spike must resolve
@neynar/nodejs-sdkundernodejs_compaton Workers — 13 routes depend on it (highest blast radius). Fallback: inline Neynar REST over nativefetch(cheap per route, but expands scope).unstable_cachereplacement — 12 routes; no Start equivalent → Cloudflare Cache API / KV (or client-side React Query staleness).- Supabase session cookie in a Start server fn — the
@supabase/ssrget/set/remove adapter;auth/callbackexchangeCodeForSession+ redirect is the make-or-break login path.
Hard-but-known work (estimable, low-novelty)
unstable_cache×12 · Sentry@sentry/nextjs→@sentry/cloudflare(rewriteinstrumentation.ts+sentry.server/edge.config.ts+ webpack plugin →@sentry/vite-plugin; reuse existing scrubbers) ·next/font(9 faces: 3 Google + 6-weight local Satoshi — must preserve the--font-sans/display/monoCSS vars feeding Tailwind) ·next/image×5 (no Start optimizer; CF Images /unpic/ plain<img>) ·embeds/metadataWASMfs.readFileSync(HIGH — bundle.wasmas Workers module/binding;outputFileTracingIncludesis Vercel-only) · ~100 mechanicalnext/server+next/navigationrewrites (codemod-able, except 18useSearchParams→ typeduseSearch).- Ports for free: PostHog (client-only), the
@faker-js/fakerwebpack stub → Viteresolve.alias,next/router(already dead, 0 importers).
API-route risk (highlights, 31 routes)
- HIGH:
embeds/metadata(WASM file-read). - MED (blast radius): 13 routes on
@neynar/nodejs-sdk; 12 routes onunstable_cache;auth/callback+oauth/decisioncookie handling. - LOW:
onchain/*(hex string-slicing only, no ABI decode),dms/*,miniapp/manifest,hypersnap/[...path]proxy,signerRequest(axios proxy). No route uses a service-role key; the one ed25519/EIP-712 signing path runs client-side (warpcastLogin), so no Workers CPU-time concern.
next/* surface (~106 files)
next/server ×34 (→ Web Request/Response, mechanical) · next/navigation ×52 (→ Router hooks; useSearchParams→typed useSearch is the only semantic shift) · next/link ×26 · next/image ×5 (HARD) · next/font ×1 file/9 faces (HARD) · next/dynamic ×4 (ssr:false semantics differ) · next/cache ×12 (HARD) · next/headers ×3 (auth cookies).
Thinnest viable spike (the Phase 0 proof)
Port app/api/feeds/trending (Neynar SDK + unstable_cache) + a getUser() cookie server-fn + one SSR route. Settles all 3 unknowns in one low-stakes slice. Excluded from the spike: embeds/metadata WASM, next/font, next/image, wallet/auth-kit (known-hard, shouldn't gate go/no-go).
Go/no-go risk matrix
| # | Risk | Effort | Mitigation |
|---|---|---|---|
| 1 | @neynar/nodejs-sdk on Workers (13 routes) |
M | probe in spike; fallback = inline Neynar REST via fetch |
| 2 | unstable_cache removal (12 routes) |
M | CF Cache API / KV, or client-side React Query |
| 3 | embeds/metadata WASM file-read |
M–L | import .wasm as Workers module; or move trek parse client-side / drop route |
| 4 | Sentry re-wire | M | @sentry/cloudflare + @sentry/vite-plugin; reuse scrubbers |
| 5 | next/font (9 faces) |
M | @fontsource + manual @font-face; keep CSS-var names |
| 6 | next/image (5) + optimizer loss |
L–M | CF Images / unpic / <img> |
| 7 | Auth cookie adapter | L | validate in spike; Supabase has framework-agnostic SSR cookie patterns |
| 8 | next/navigation volume (52 files) |
M | codemod the mechanical ~80%; hand-port 18 useSearchParams |
Not risks (de-riskers): no middleware.ts, no server-side service-role secret, no CPU-bound/edge routes, one async server-component page, PostHog client-only, faker stub portable, next/router dead.
Key files (spike + decision)
app/api/feeds/trending/route.ts, app/api/embeds/metadata/route.ts, src/common/helpers/supabase/route.ts, app/api/auth/callback/route.ts, app/oauth/consent/page.tsx, app/layout.tsx, app/providers.tsx, next.config.mjs, src/instrumentation.ts, sentry.server.config.ts, sentry.edge.config.ts, src/lib/faker-stub.ts.
Environment note
Actual wrangler deploy needs Cloudflare credentials (not available in the planning sandbox). The top unknown — Neynar SDK on workerd — may be probeable locally via wrangler dev without a CF account.
Relationships
- Parent: [Epic] Sub-200ms responsiveness + migrate to TanStack Start on Cloudflare Workers #744. Absorbs [Responsiveness] Prefetch-on-intent for profile/cast navigation #737 (prefetch-on-intent) + the prefetch half of [Responsiveness] Instant feed switching (keepPreviousData + adjacent prefetch) #736.
- Independent of Track A responsiveness work ([Responsiveness] Persist React Query cache to IndexedDB (kill cold-start blank feed) #735/[Responsiveness] Snappy notification UX (optimistic read-state, decouple from 5s sync) #738/[Measurement] INP / action-to-paint instrumentation (web-vitals onINP) #743 ✅ done; [Responsiveness] Instant feed switching (keepPreviousData + adjacent prefetch) #736/[Responsiveness] Optimistic feed insertion on publish #739/[Responsiveness] Trim Phase-1 store init off the critical path (interactive < 200ms) #740/[Responsiveness] Optimistic account/channel mutations (add/remove/pin) #741 in flight) — but best landed after Track A so we migrate an already-fast app.
- Data-layer evaluation [Discovery] Evaluate a rawer, pooled Postgres data layer vs. the full Supabase stack #742 (Later) — Cloudflare Hyperdrive enables pooled Postgres post-migration.
Activity
hellno commentedon Jun 4, 2026
Phase 0 spike complete — running PoC on Cloudflare Workers (workerd)
Branch:
spike/tanstack-start-phase0(offmain, pushed for reference — not for merge, no PR). Throwaway slice: one SSR route (loader + twocreateServerFns rendering a server-loaded cast list) + a/api/probeJSON endpoint + 3 server-only libs that portapp/api/feeds/trending/route.tsand the Supabase cookie path. Run on real workerd viawrangler devon thevite buildoutput —nodejs_compaton,compatibility_date 2026-06-04. ("userAgent": "Cloudflare-Workers"in the output below is workerd self-identifying, not Node.)Verdict: GO. 3/3 unknowns = WORKS on workerd — and the #1 risk (Neynar SDK) needed no fallback.
@neynar/nodejs-sdk@1.21.1(axios) undernodejs_compatunstable_cachereplacementQ1 —
@neynar/nodejs-sdk@1.21.1on workerd → ✅ WORKSThe SDK exactly as herocast uses it (v1 string constructor:
new NeynarAPIClient(key).fetchTrendingFeed({ limit })) imports, bundles, and runs on workerd, returning real trending casts.curl localhost:8799/api/probe(underwrangler dev):vite buildbundled the SDK + axios + viem into the workerd SSR chunk with no error (1439 modules transformed; server chunk 2.6 MB; whole worker = 616 KB gzip, under the 3 MB Worker limit).Surprise / why it works: static analysis predicted trouble — the SDK pulls
axios@1.17.0, whosehttpadapter is selected whenprocessexists (true undernodejs_compat). Empiricallynodejs_compatprovides enoughnode:http/node:streamfor axios's outbound HTTPS to succeed. (The heavy@openapitools/openapi-generator-clidep is codegen-only and is not bundled.)Effort impact: the highest-blast-radius risk collapses — keep the SDK as-is across all 13 routes. The inline-REST fallback is built and also works, but is unnecessary. Only watch item: the 2.6 MB server chunk is mostly axios+viem; fine today, worth trimming (→ REST) later if the worker nears size limits.
Q2 —
unstable_cachereplacement → ✅ WORKS (Cloudflare Cache API)caches.defaultis present on workerd and persists across requests:Effort impact: drop-in helper
withCacheAPI(key, ttlSeconds, producer)(TTL viaCache-Control: max-age) replacesunstable_cacheper route — mechanical, not novel. Cache API is per-colo; for global / non-HTTP-shaped values use KV (works in local dev with no CF account). Risk #2 stays M by volume but is de-risked.Q3 — Supabase session cookie in a Start server fn → ✅ WORKS
getRequest()(from@tanstack/react-start/server— note:getWebRequestwas renamedgetRequestin 1.168) →@supabase/ssrcreateServerClientwith thegetAll/setAllcookie adapter (the 0.6+ API; the current Next code still uses the deprecatedget/set/removeform) →auth.getUser():Full path proven on workerd: cookie read → session decoded → access_token extracted → network validation call. Only a real Supabase backend (out of spike scope — issue assumes a migrated working instance) is needed for a live user. TanStack Start also ships first-class
getCookie / getCookies / setCookie / useSessionprimitives.Effort impact: Risk #7 confirmed L.
auth/callbackexchangeCodeForSession+ Set-Cookie viasetAll/setCookieis a straightforward port. (Unrelated, still pending: theuseSearchParams → useSearchsemantic shift in 18 files.)Incidental confirmations
@cloudflare/vite-plugin+tanstackStart()+viteReact(),main: "@tanstack/react-start/server-entry",compatibility_flags: ["nodejs_compat"]. (C3--framework=tanstack-startexists per the Oct-2025 CF changelog but the registry key is version-flaky; hand-rolled config is the reliable path.)pnpm rebuild esbuild workerdor wrangler/vite can't boot workerd.wrangler deploy --dry-runvalidates a deployable artifact (616 KB gzip). Re-verified blast radius: 13 SDK routes, 12unstable_cacheroutes.@tanstack/react-start@1.168·react@19.2·@neynar/nodejs-sdk@1.21.1(→axios@1.17,viem@1) ·@supabase/ssr@0.8·@cloudflare/vite-plugin@1.40·wrangler@4.98·vite@6.4·workerd@1.20260603.Environment caveat (honest)
No Cloudflare credentials in this sandbox (
wrangler whoami→ unauthenticated), so a real edgewrangler deploywas NOT performed. All results are from local workerd (wrangler devon the built bundle) +deploy --dry-run. Local workerd == prod workerd runtime, so the three runtime answers hold; the only unverified step is the live deploy + secrets handshake.GO / NO-GO: GO
Phase 0 materially de-risks the migration: the highest-blast-radius unknown (Neynar SDK, 13 routes) is a non-issue, and Q2/Q3 are mechanical ports with proven patterns. Remaining cost is volume + the known-hard items this spike deliberately excluded (embeds/metadata WASM,
next/font,next/image) — exactly as the discovery audit predicted.Single biggest remaining unknown
The
embeds/metadataWASMfs.readFileSyncroute (Risk #3) — the one route excluded from this spike and the only HIGH left.outputFileTracingIncludesis Vercel-only; on Workers the.wasmmust be bundled as a Workers module/binding (or the parse moved client-side / the route dropped). That, plus the live edgewrangler deploy+ secrets handshake, are the next things to validate.Reference branch
spike/tanstack-start-phase0(seespike-tanstack-start/README.md+FINDINGS.mdfor verbatim runs). Not opening a PR — the code is reference, the findings are the deliverable.spike(#754): harden /api/probe — wrap q2/q3 so missing prod secrets r…
spike(#754): read env via cloudflare:workers binding (process.env unr…
spike(#754): fix Q3 cookie smells (codex review)
hellno commentedon Jun 4, 2026
Phase 0 confirmed on real Cloudflare hardware — GO stands (+ two evidence corrections)
Following the findings above: the spike was deployed to real Cloudflare Workers (
wrangler deploy, edge workerd — not just local) and all three unknowns were re-verified on the edge. Verdict unchanged: GO, now validated on hardware. Reference branchspike/tanstack-start-phase0(spike-tanstack-start/README.md+FINDINGS.mdhave verbatim runs).Live edge scorecard
wrangler deploy→ 2 files, 616 KB gzip, 23 ms cold start (forkability bar met)@neynar/nodejs-sdk@1.21.1on workerdunstable_cache→ CF Cache APIfetchedAt)getUser()/auth/v1/userRefreshed risk matrix (vs. the discovery audit)
nodejs_compat; keep as-is (REST fallback built but unused)unstable_cacheremoval (12 routes)withCacheAPI(key, ttl, producer); KV for global/non-HTTP valuesgetRequest()+@supabase/ssrgetAll/setAll+getUser()New gotchas the spike surfaced (not in the audit — fold into Phase 1)
process.envis populated from secrets on the edge at compat date2026-06-04(a de-risker — existingprocess.env.NEXT_PUBLIC_*reads port). The Cloudflare-canonical read is stillimport { env } from 'cloudflare:workers'; the spike uses it with aprocess.envfallback..dev.varsis dev-only — production needswrangler secret put(orvars). This belongs in the forkability secrets template.getWebRequest()→getRequest()in@tanstack/react-start≥1.168.pnpm rebuild esbuild workerd.axios+viem→ a 2.6 MB server chunk (616 KB gzip, fine vs the 3 MB compressed limit, but watch it as 30+ routes land; REST-for-hot-paths is the lever).Two corrections to the evidence (for the record)
process.envon the edge — works (above). Any earlier worry about needing an env shim is moot; it's a de-risker, not a risk.@supabase/ssrdecodes base64url) and reportednetworkValidationAttemptedfrom "ansb-cookie exists" rather than the actual network outcome. Both fixed: the test cookie is now built with the SDK's ownstringToBase64URL, and the network signal is inferred from the error class. Conclusion is unchanged — Q3 works — but the evidence is now faithful, and confirmed on the edge against a real Supabase backend (a ref-matched cookie with a fake token returns a genuine401 AuthApiErrorfrom/auth/v1/user, proving the full round-trip).Remaining unknowns / explicitly out of the spike (the Phase 1 pre-flight)
embeds/metadataWASMfs.readFileSync— the only HIGH left; bundle.wasmas a Workers module, move parse client-side, or drop the route.exchangeCodeForSession+ Set-Cookie viasetAll, incl. chunkedsb-*.0/.1). The spike proved cookie read, not the write/login round-trip.@sentry/nextjs→@sentry/cloudflare+@sentry/vite-plugin).next/font(9 faces) ·next/image(5) ·next/navigation(52 files; 18useSearchParams→ typeduseSearch).Bottom line: GO confirmed on real Cloudflare. The highest-blast-radius unknown (Neynar SDK) is a non-issue; Q2/Q3 are mechanical with proven patterns. Phase 1 can proceed; the checklist above is its pre-flight.
spike(#754): Phase 0.5 — de-risk WASM + auth-write on workerd (codex-…
hellno commentedon Jun 5, 2026
Phase 0.5 complete — the two unknowns the spike skipped are also resolved (workerd)
Ran two more probes on real workerd (
wrangler devon the built bundle), then codex-reviewed and corrected. Both resolve to WORKS. Reference branchspike/tanstack-start-phase0→spike-tanstack-start/PHASE-0.5.md(verbatim output).0.5a —
embeds/metadataWASM (@officialunofficial/trek) — the only HIGH risk → ✅ WORKSThe prod route loads the WASM via
readFileSync(...trek_rs_bg.wasm)+initSync({ module: buffer }), which can't run on workerd (outputFileTracingIncludesis Vercel-only). The workerd-native fix is a Workers module import:@cloudflare/vite-pluginbundles the.wasm(compiled-wasm), workerd compiles it at deploy,initSyncdoes a syncnew WebAssembly.Instance(module). Trek parsed sample HTML and a live-fetched github.com page on workerd (~30 ms parse)..wasm≈ 698 KB gzip; whole worker ≈ 1.3 MB gzip — under the 3 MB compressed Worker limit (free), but this is now the bundle-size growth to watch.parse()CPU on very large/hostile HTML vs Workers CPU limits (real pages parsed <35 ms).0.5b — auth WRITE / login cookie path (chunked
sb-*.0/.1) → ✅ WORKSOn a real
exchangeCodeForSession,@supabase/ssrsaves the session asbase64-+base64url(JSON), chunks it (createChunks), and writes each chunk via thesetAlladapter →Set-Cookie. Probe forces a 4.2 KB session → 2 chunks, writes them via TanStack Start'ssetCookiewith the library'sDEFAULT_COOKIE_OPTIONS:Replaying those back through the read path:
combineChunksreassembled the session andgetUser()reached the network (networkValidationAttempted: true). Write → chunk → read → reassemble all work on workerd.getUseragainst a live project — both are afetch+ the already-proven read path.codex review caught 2 real issues (both fixed)
app/api/embeds/metadata/route.ts:126,142-143) readresult.metadata.*, but trek 0.2.1serde(flatten)s metadata to the result root —result.metadataisundefined. Correct fields are top-levelresult.title/description/image. (This is a pre-existing prod bug unrelated to the migration — tracking separately.)setCookie(not a hand-rolledResponse) + the library's real cookie defaults.Net
No HIGH-risk unknowns remain before Phase 1. Everything left is known-mechanical: Sentry rewire (
@sentry/nextjs→@sentry/cloudflare),next/font(9 faces),next/image(5),next/navigationvolume (52 files; 18useSearchParams→useSearch).chore(#754): upgrade React 18 → 19 (spike-validated, clean) (#759)
feat(#754): host-portability seams + Vercel target spike (NO-GO on vi…
docs(#754): migration operating model — strategy map, reuse contract,…
docs(#754): unit #2 navigation-seam spec + parallel agent execution p…
feat(#754 nav): next/navigation → TanStack adapter + build aliases (u…
19 remaining items
docs(#754 feeds-profile): record PR #768 in strategy.md #6 row
feat(#754 feeds-profile): unit #6 — feeds + profile on TanStack (#768)
docs(#754 inbox-search): add PR #769 ref to unit #7 status cell
docs(#754 editor): add PR #770 ref to strategy.md #8 row
docs(#754): add PR #771 ref to unit #9 status cell
feat(#754 auth-accounts): unit #9 — auth + accounts + onboarding on T…
feat(#754 editor): unit #8 — TipTap editor + embeds on TanStack (#770)
feat(#754 inbox-search): unit #7 — inbox + search + conversation on T…
docs(#754): record Deno desktop spike results (ran it — seams PASS, b…
docs(#754): Deno desktop tested on-device — Chromium/CEF, 295MB macOS…
docs(#754): correct desktop size story — Deno desktop has selectable …
chore(#754 desktop): remove Tauri from the repo; commit to Deno deskt…
hellno commentedon Jun 23, 2026
Desktop consumer (resolves
phase-1.md §10) — decided + sequenced.Direction: wrap the TanStack app in the Deno desktop runtime (the desktop app is the TanStack app). Tauri removed from the repo (dormant). Spike verified the host seams fall through on Deno + a build launches/renders end-to-end on macOS. Full plan:
docs/migration/desktop-deno-runtime.md.Three proof-driven follow-ups, all gated on this epic's #13 cutover (+
deno desktopleaving canary):deno desktop(TARGET=deno build) #772 — D1: run the real app underdeno desktop(TARGET=denobuild)docs(#754 desktop): wire D1–D3 plan to filed issues #772/#773/#774
docs(#754): desktop consumer = Deno desktop runtime; remove Tauri; D1…