Skip to main content

ADR-0034: The artists portal runs on TanStack Start, not Next.js

Status: Accepted

Date: 2026-09-08

Context​

ADR-0003 made the Next.js App Router the repository standard and recorded "TanStack Router SPA" as an alternative rejected at the time — "valid for admin-style SPAs, but rejected because this product already relies on Next.js App Router and server rendering." That reasoning was written about apps/web-player, where it still holds.

apps/web-artists is a different case. Under ADR-0032 the artist surface is where new investment goes, and it is still almost entirely unbuilt: 84 files, five routes, no authenticated area, and — at the time of this decision — no tests at all. It used almost none of what Next gives: no Server Components beyond the default, no route handlers, no ISR, no next/font beyond one face, and an edge middleware whose protected-prefix list was empty. What it did carry was the whole Next build surface.

Moving it costs the least it will ever cost, and every month of new artist work makes the migration more expensive without making it less likely.

Decision​

apps/web-artists runs on TanStack Start (Vite 8, Nitro, TanStack Router), with server rendering kept. apps/web-player stays on the Next.js App Router; ADR-0003 is unchanged for it.

The FSD layers — views/, widgets/, features/, entities/, shared/ — are untouched. Only the framework layer moved: src/app/ became src/routes/, and src/router.tsx owns the router factory.

ADR-0004 survives intact: the portal still uses openapi-fetch with the JWT refresh middleware and TanStack Query, and still takes its types from @bitrate/contracts.

Three boundary details are part of the decision, because each fails silently if reversed:

  • Client environment variables are VITE_-prefixed and inlined at build time. The deployment variable stays NEXT_PUBLIC_API_URL — it is shared with apps/web-player — and is mapped to VITE_API_URL at this app's Dockerfile build arg, its compose service, and its CI build args. Setting it at container runtime does nothing.
  • Nginx proxies /assets/, not /_next/static/. That location exists to keep build output out of the page rate limit; a stale path would have sent every stylesheet and chunk through it and answered a normal page load with 503s.
  • The Nitro Vite plugin is load-bearing. Plain vite build emits dist/server/server.js, a fetch handler that exits immediately under node. Nitro is what produces the runnable .output/server/index.mjs the image starts.

src/routeTree.gen.ts is generated by the router plugin and committed, so tsc --noEmit and CI type checks run without a build first. It is excluded from lint rather than hand-edited.

Consequences​

  • The two web apps no longer share a framework. A web-player pattern is not automatically valid in the portal. br-frontend-developer owns both and now says so explicitly.
  • Next's conveniences were replaced by hand: the Metadata API and file-based OG images became head entries in __root.tsx with assets in public/; next/image became <img>; next/font/google became a stylesheet link plus a --font-league-spartan token.
  • The empty edge middleware was dropped, not ported. Porting a no-op would have shipped dead code that fails pnpm knip. What it knew — the server-side cookie read, the REFRESH_TOKEN_NAME default, the ?next= redirect — is written down in the app's README for whoever builds the authenticated area.
  • The portal gained its first tests. A Vitest project with 18 cases, 12 of them negative paths, configured separately from vite.config.ts so unit tests do not load the Start and Nitro plugins.
  • Validation moved to zod 4. .email() on a string is deprecated there, and the obvious replacement — a bare z.email() — would have answered an empty field with "invalid email" instead of "Email is required", so the schemas pipe .min(1) into z.email() and a spec locks that distinction in.
  • openapi-react-query remains an unused dependency in this app. It predates this change and is left in place rather than removed as a drive-by; pnpm knip reports it.
  • The portal cannot hold dependency versions of its own. nodeLinker: hoisted gives every workspace one resolved copy, so raising a shared package here raises it for apps/web-player and packages/ui-react too. Two attempted upgrades were reverted for exactly that reason during this migration: zod to 4.5 broke zodResolver typing in ui-react, and openapi-fetch to 0.17 turned roughly thirty of the web player's query hooks into never. zod is now pinned in pnpm.overrides so the break cannot reappear from a routine install — see .claude/rules/monorepo.md. Bumping either package is its own change, coordinated across the workspaces that share it.

Alternatives considered​

  • Leave the portal on Next.js — rejected. It used little of the framework and the cost of moving only grows as the artist product is built out. Doing it while the app is five routes is the cheapest this decision will ever be.
  • Move both web apps at once — rejected. apps/web-player is 596 files and genuinely uses Server Components, streaming, and the Metadata API; the portal was the low-risk place to prove the toolchain first. Whether the player follows is a separate decision, not a foregone one.
  • TanStack Start in SPA mode — rejected. The portal's landing page is public and needs to be server-rendered for SEO, and dropping SSR would have been a product regression disguised as a simplification.
  • Keep NEXT_PUBLIC_API_URL by widening Vite's envPrefix — rejected. It would have avoided touching infrastructure, at the price of a variable whose name asserts a framework the app no longer uses.