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 staysNEXT_PUBLIC_API_URL— it is shared withapps/web-player— and is mapped toVITE_API_URLat 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 buildemitsdist/server/server.js, a fetch handler that exits immediately undernode. Nitro is what produces the runnable.output/server/index.mjsthe 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-developerowns 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.tsxwith assets inpublic/;next/imagebecame<img>;next/font/googlebecame a stylesheet link plus a--font-league-spartantoken. - 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, theREFRESH_TOKEN_NAMEdefault, 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.tsso 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 barez.email()— would have answered an empty field with "invalid email" instead of "Email is required", so the schemas pipe.min(1)intoz.email()and a spec locks that distinction in. openapi-react-queryremains an unused dependency in this app. It predates this change and is left in place rather than removed as a drive-by;pnpm knipreports it.- The portal cannot hold dependency versions of its own.
nodeLinker: hoistedgives every workspace one resolved copy, so raising a shared package here raises it forapps/web-playerandpackages/ui-reacttoo. Two attempted upgrades were reverted for exactly that reason during this migration:zodto 4.5 brokezodResolvertyping inui-react, andopenapi-fetchto 0.17 turned roughly thirty of the web player's query hooks intonever.zodis now pinned inpnpm.overridesso 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-playeris 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_URLby widening Vite'senvPrefix— rejected. It would have avoided touching infrastructure, at the price of a variable whose name asserts a framework the app no longer uses.