Skip to content

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

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

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