Skip to content

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

@hellno

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions