Skip to content

[Epic] Migrate Next.js 15 → TanStack Start (SSR) on Cloudflare Workers #754

Open
0 of 1 issue completed

Description

@hellno

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:

  1. Merge Upgrade React 18 → 19 #759 (React 19) — the prerequisite.
  2. Build the Phase 1 foundation in-place: TanStack vite.config.ts + wrangler.jsonc + src/routes/ + providers/auth/next-font→@fontsource+@font-face/next-image primitives at the repo root, reusing the existing src/globals.css / fonts / tailwind.config.js (no copies). Next keeps running alongside until Phase 4.
  3. Phases 2–4 as below.

The throwaway web/-folder Phase-1 spike lives on branch feat/tanstack-start-phase1 (reference only — its auth-cookie wiring, font replacement, and next/image primitive port directly to the in-place layout). It will not be merged.

Why (drivers)

Forkability bar (scope)

  • Target: wrangler deploy + a secrets template (.dev.vars / wrangler.toml example) stands up a fork.
  • Assumes an already-migrated, working Supabase instance. Supabase bootstrap / seed data is out of scope.

Success criteria

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); replace next/font + next/image reusing the existing src/globals.css / fonts / tailwind.config.js; secrets template. (See the feat/tanstack-start-phase1 reference 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

  1. @neynar/nodejs-sdk under nodejs_compat on Workers — 13 routes depend on it (highest blast radius). Fallback: inline Neynar REST over native fetch (cheap per route, but expands scope).
  2. unstable_cache replacement — 12 routes; no Start equivalent → Cloudflare Cache API / KV (or client-side React Query staleness).
  3. Supabase session cookie in a Start server fn — the @supabase/ssr get/set/remove adapter; auth/callback exchangeCodeForSession + redirect is the make-or-break login path.

Hard-but-known work (estimable, low-novelty)

  • unstable_cache ×12 · Sentry @sentry/nextjs → @sentry/cloudflare (rewrite instrumentation.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/mono CSS vars feeding Tailwind) · next/image ×5 (no Start optimizer; CF Images / unpic / plain <img>) · embeds/metadata WASM fs.readFileSync (HIGH — bundle .wasm as Workers module/binding; outputFileTracingIncludes is Vercel-only) · ~100 mechanical next/server + next/navigation rewrites (codemod-able, except 18 useSearchParams → typed useSearch).
  • Ports for free: PostHog (client-only), the @faker-js/faker webpack stub → Vite resolve.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 on unstable_cache; auth/callback + oauth/decision cookie 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

Activity

  1. hellno commented on Jun 4, 2026

    @hellno
    CollaboratorAuthor

    Phase 0 spike complete — running PoC on Cloudflare Workers (workerd)

    Branch: spike/tanstack-start-phase0 (off main, pushed for reference — not for merge, no PR). Throwaway slice: one SSR route (loader + two createServerFns rendering a server-loaded cast list) + a /api/probe JSON endpoint + 3 server-only libs that port app/api/feeds/trending/route.ts and the Supabase cookie path. Run on real workerd via wrangler dev on the vite build output — nodejs_compat on, 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.

    # Unknown Result Effort impact
    1 @neynar/nodejs-sdk@1.21.1 (axios) under nodejs_compat ✅ WORKS (no fallback needed) Risk #1 (13 routes): M → ~0
    2 unstable_cache replacement ✅ WORKS (CF Cache API) Risk #2 (12 routes): M, de-risked → mechanical
    3 Supabase cookie in a Start server fn ✅ WORKS Risk #7: L, confirmed

    Q1 — @neynar/nodejs-sdk@1.21.1 on workerd → ✅ WORKS

    The 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 (under wrangler dev):

    "runtime":        { "userAgent": "Cloudflare-Workers", "hasProcess": true, "hasCachesDefault": true },
    "q1_neynar_sdk":  { "ok": true, "count": 3, "firstAuthor": "irynaliakh.eth" },
    "q1_neynar_rest": { "ok": true, "count": 3, "firstAuthor": "irynaliakh.eth" }

    vite build bundled 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, whose http adapter is selected when process exists (true under nodejs_compat). Empirically nodejs_compat provides enough node:http/node:stream for axios's outbound HTTPS to succeed. (The heavy @openapitools/openapi-generator-cli dep 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_cache replacement → ✅ WORKS (Cloudflare Cache API)

    caches.default is present on workerd and persists across requests:

    // request 1:  "q2_cache": { "cacheStatus": "MISS", "fetchedAt": "2026-06-04T21:10:11.933Z" }
    // request 2:  "q2_cache": { "cacheStatus": "HIT",  "fetchedAt": "2026-06-04T21:10:11.933Z" }   // identical timestamp = served from cache

    Effort impact: drop-in helper withCacheAPI(key, ttlSeconds, producer) (TTL via Cache-Control: max-age) replaces unstable_cache per 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: getWebRequest was renamed getRequest in 1.168) → @supabase/ssr createServerClient with the getAll/setAll cookie adapter (the 0.6+ API; the current Next code still uses the deprecated get/set/remove form) → auth.getUser():

    // no cookie:        user: null, error: AuthSessionMissingError          (clean, no crash)
    // synthetic cookie: "supabaseCookiesSeen": ["sb-…-auth-token"],
    //                   "networkValidationAttempted": true,
    //                   error: AuthRetryableFetchError                       (getUser() reached {url}/auth/v1/user; fails only because the host is a dummy)

    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 / useSession primitives.

    Effort impact: Risk #7 confirmed L. auth/callback exchangeCodeForSession + Set-Cookie via setAll/setCookie is a straightforward port. (Unrelated, still pending: the useSearchParams → useSearch semantic shift in 18 files.)


    Incidental confirmations

    • Scaffold that works: @cloudflare/vite-plugin + tanstackStart() + viteReact(), main: "@tanstack/react-start/server-entry", compatibility_flags: ["nodejs_compat"]. (C3 --framework=tanstack-start exists per the Oct-2025 CF changelog but the registry key is version-flaky; hand-rolled config is the reliable path.)
    • Gotcha: pnpm v10 skips native build scripts → must pnpm rebuild esbuild workerd or wrangler/vite can't boot workerd.
    • Server/client split works: neynar + supabase stay out of the 332 KB client bundle.
    • wrangler deploy --dry-run validates a deployable artifact (616 KB gzip). Re-verified blast radius: 13 SDK routes, 12 unstable_cache routes.
    • Versions proven: @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 edge wrangler deploy was NOT performed. All results are from local workerd (wrangler dev on 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/metadata WASM fs.readFileSync route (Risk #3) — the one route excluded from this spike and the only HIGH left. outputFileTracingIncludes is Vercel-only; on Workers the .wasm must be bundled as a Workers module/binding (or the parse moved client-side / the route dropped). That, plus the live edge wrangler deploy + secrets handshake, are the next things to validate.

    Reference branch spike/tanstack-start-phase0 (see spike-tanstack-start/README.md + FINDINGS.md for verbatim runs). Not opening a PR — the code is reference, the findings are the deliverable.

  2. added 3 commits that reference this issue on Jun 4, 2026
  3. hellno commented on Jun 4, 2026

    @hellno
    CollaboratorAuthor

    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 branch spike/tanstack-start-phase0 (spike-tanstack-start/README.md + FINDINGS.md have verbatim runs).

    Live edge scorecard

    Real edge result
    Deploy + routing ✅ wrangler deploy → 2 files, 616 KB gzip, 23 ms cold start (forkability bar met)
    Q1 @neynar/nodejs-sdk@1.21.1 on workerd ✅ SDK runs + makes real authenticated requests; no REST fallback needed
    Q2 unstable_cache → CF Cache API ✅ proven on local workerd (MISS→HIT, same fetchedAt)
    Q3 Supabase cookie → getUser() ✅ proven on edge: cookie read → session decoded → real HTTPS call to the real Supabase /auth/v1/user

    Refreshed risk matrix (vs. the discovery audit)

    # Risk Was Now
    1 Neynar SDK on Workers (13 routes) M ~0 — SDK works under nodejs_compat; keep as-is (REST fallback built but unused)
    2 unstable_cache removal (12 routes) M M, mechanical — drop-in withCacheAPI(key, ttl, producer); KV for global/non-HTTP values
    7 Auth cookie adapter L L, confirmed — getRequest() + @supabase/ssr getAll/setAll + getUser()

    New gotchas the spike surfaced (not in the audit — fold into Phase 1)

    • Env access: process.env is populated from secrets on the edge at compat date 2026-06-04 (a de-risker — existing process.env.NEXT_PUBLIC_* reads port). The Cloudflare-canonical read is still import { env } from 'cloudflare:workers'; the spike uses it with a process.env fallback.
    • Secrets: .dev.vars is dev-only — production needs wrangler secret put (or vars). This belongs in the forkability secrets template.
    • API change: getWebRequest() → getRequest() in @tanstack/react-start ≥1.168.
    • Tooling: pnpm v10 skips native build scripts → must pnpm rebuild esbuild workerd.
    • Bundle size: Neynar SDK pulls 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)

    1. process.env on the edge — works (above). Any earlier worry about needing an env shim is moot; it's a de-risker, not a risk.
    2. Q3 cookie evidence rebuilt after a code review. The first cut's synthetic cookie used standard base64 (works only by luck for that payload — @supabase/ssr decodes base64url) and reported networkValidationAttempted from "an sb- cookie exists" rather than the actual network outcome. Both fixed: the test cookie is now built with the SDK's own stringToBase64URL, 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 genuine 401 AuthApiError from /auth/v1/user, proving the full round-trip).

    Remaining unknowns / explicitly out of the spike (the Phase 1 pre-flight)

    • embeds/metadata WASM fs.readFileSync — the only HIGH left; bundle .wasm as a Workers module, move parse client-side, or drop the route.
    • Full auth WRITE path — login (exchangeCodeForSession + Set-Cookie via setAll, incl. chunked sb-*.0/.1). The spike proved cookie read, not the write/login round-trip.
    • Sentry rewire (@sentry/nextjs → @sentry/cloudflare + @sentry/vite-plugin).
    • next/font (9 faces) · next/image (5) · next/navigation (52 files; 18 useSearchParams → typed useSearch).

    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.

  4. added a commit that references this issue on Jun 4, 2026
  5. hellno commented on Jun 5, 2026

    @hellno
    CollaboratorAuthor

    Phase 0.5 complete — the two unknowns the spike skipped are also resolved (workerd)

    Ran two more probes on real workerd (wrangler dev on the built bundle), then codex-reviewed and corrected. Both resolve to WORKS. Reference branch spike/tanstack-start-phase0 → spike-tanstack-start/PHASE-0.5.md (verbatim output).

    0.5a — embeds/metadata WASM (@officialunofficial/trek) — the only HIGH risk → ✅ WORKS

    The prod route loads the WASM via readFileSync(...trek_rs_bg.wasm) + initSync({ module: buffer }), which can't run on workerd (outputFileTracingIncludes is Vercel-only). The workerd-native fix is a Workers module import:

    import * as trek from '@officialunofficial/trek';
    import trekWasm from '@officialunofficial/trek/trek_rs_bg.wasm'; // → WebAssembly.Module
    trek.initSync({ module: trekWasm });                            // module scope, per isolate

    @cloudflare/vite-plugin bundles the .wasm (compiled-wasm), workerd compiles it at deploy, initSync does a sync new WebAssembly.Instance(module). Trek parsed sample HTML and a live-fetched github.com page on workerd (~30 ms parse).

    • Bundle cost: trek .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.
    • Not measured: 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) → ✅ WORKS

    On a real exchangeCodeForSession, @supabase/ssr saves the session as base64-+base64url(JSON), chunks it (createChunks), and writes each chunk via the setAll adapter → Set-Cookie. Probe forces a 4.2 KB session → 2 chunks, writes them via TanStack Start's setCookie with the library's DEFAULT_COOKIE_OPTIONS:

    set-cookie: sb-…-auth-token.0=base64-<…>; Max-Age=34560000; Path=/; Secure; SameSite=Lax
    set-cookie: sb-…-auth-token.1=<…>;       Max-Age=34560000; Path=/; Secure; SameSite=Lax
    

    Replaying those back through the read path: combineChunks reassembled the session and getUser() reached the network (networkValidationAttempted: true). Write → chunk → read → reassemble all work on workerd.

    • Not proven (out of scope, low risk): the live OAuth code exchange (needs a real auth code) and a successful getUser against a live project — both are a fetch + the already-proven read path.

    codex review caught 2 real issues (both fixed)

    • The first WASM probe (and the prod route, app/api/embeds/metadata/route.ts:126,142-143) read result.metadata.*, but trek 0.2.1 serde(flatten)s metadata to the result root — result.metadata is undefined. Correct fields are top-level result.title/description/image. (This is a pre-existing prod bug unrelated to the migration — tracking separately.)
    • The auth-write probe was rewritten to use the framework setCookie (not a hand-rolled Response) + 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/navigation volume (52 files; 18 useSearchParams→useSearch).

  6. added a commit that references this issue on Jun 5, 2026
  7. added 4 commits that reference this issue on Jun 9, 2026
  8. 19 remaining items

  9. added 12 commits that reference this issue on Jun 20, 2026
  10. hellno commented on Jun 23, 2026

    @hellno
    CollaboratorAuthor

    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 desktop leaving canary):

  11. added 2 commits that reference this issue on Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions