(motir-core) app.motir.co/legal/<slug> answers 500 for EVERY slug since the manifest reader deployed — `generateStaticParams` over a runtime-only env var yields no params at build, so the redirect stub renders statically and the locale cookie read throws
Opened by Zhu Yue ·
Type: code · Parent: MOTIR-3875 (the discovery epic — the edge test says no: nothing in MOTIR-4101 is blocked by this fix, and the deletion card removes the route whether or not it is fixed first) · Discovered in: MOTIR-4102, while reading the deployed application · Repo: motir-core · ONE PR.
The symptom, measured on production (2026-09-02, release v239, image GH_SHA=ac5f9ac1)
GET https://app.motir.co/legal/terms → HTTP 500
GET https://app.motir.co/legal/dpa → HTTP 500
GET https://app.motir.co/legal/no-such-doc → HTTP 500 (should be 404)
GET https://app.motir.co/legal → HTTP 200 (the index still renders)
fly logs -a motir-core, at the second of each request:
⨯ [Error: An error occurred in the Server Components render. …] { digest: 'DYNAMIC_SERVER_USAGE' }
This is new with the MOTIR-3909 deploy. MOTIR-4012's readback, taken this morning on release v230, recorded /legal/terms → 200 from the old disk-based reader. Release v239 is the first image built from a commit containing the 3909 merge (8d80ac8d), and it answers 500.
Root cause — VERIFIED against the deployed build, not a reading theory
app/(public)/legal/[slug]/page.tsx was rewritten by MOTIR-4007 into a permanentRedirect(doc.url) stub — and kept its generateStaticParams(), which now returns legalDocumentSlugs(), i.e. a read of MOTIR_LEGAL_DOCUMENTS. That variable is a Fly runtime secret; it is absent when CI runs next build, so at build time generateStaticParams returns [].
Three consequences follow, and the deployed image shows each of them:
- The route is classified STATIC with zero prerendered params.
/app/.next/prerender-manifest.jsoninside the running machine lists/legal/[slug]underdynamicRouteswithfallback: nulland no entry underroutes— a static route whose params are generated on demand. (Read viafly ssh console, 2026-09-02.) - Every request is therefore an on-demand STATIC render of an unlisted param, and in that mode any dynamic API throws. The render reaches one before the page body runs: the
(public)/legallayout callsgetTranslations(app/(public)/legal/layout.tsx:38, and again inExploreTopBar/ExploreFooter), and next-intl's request config reads the locale cookie —i18n/request.ts:6,(await cookies()).get('NEXT_LOCALE').cookies()inside a static render is exactly what Next reports asDYNAMIC_SERVER_USAGE. NeithernotFound()norpermanentRedirect()is ever reached, which is why an unknown slug 500s instead of 404ing. - Why it worked before 4007:
generateStaticParamsused to return the seven slugs read fromcontent/legal/on disk, which exist at build time. Next tried to prerender them, hit the samecookies()call, and bailed the route out to dynamic rendering at build — the documented behaviour for dynamic-API use during prerender. With no params to attempt, the bail-out never happens and the route ships static.
/legal (the index) has no generateStaticParams, so Next inferred its dynamic-ness from the same cookies() call at build and it renders fine. That asymmetry is the whole diagnosis.
Why nothing masks it in production: the page's own comment says the route is only reached on a build whose MOTIR_PUBLIC_SITE_URL is unset, because proxy.ts:76 otherwise 308s /legal/* to the public host (MOTIR-3884). fly secrets list -a motir-core shows no MOTIR_PUBLIC_SITE_URL — the cutover (MOTIR-3910) is still blocked — so the stub IS the live answer for every old /legal/<slug> link (bookmarks, pre-4010 sign-up notices, emails).
Reproduce
curl -sS -o /dev/null -w '%{http_code}\n' https://app.motir.co/legal/terms # 500
fly logs -a motir-core --no-tail | grep -A2 'Server Components render' # digest: 'DYNAMIC_SERVER_USAGE'
Locally: next build with MOTIR_LEGAL_DOCUMENTS unset, then next start with it set and request /legal/terms.
Fix direction
- Delete
generateStaticParamsfromapp/(public)/legal/[slug]/page.tsx. A manifest that is runtime configuration can never yield params on a build server, so the export is dead by construction and its only effect is to mis-classify the route. (export const dynamic = 'force-dynamic'is the equivalent one-liner; removing the misleading export is the honest one.) Update the file's THIS IS A WINDOW comment, which currently claims the stub "stays reachable" while it 500s. - A test that would have gone red: a route test that renders
/legal/<known slug>with the manifest set and asserts a 308 to the manifesturl, and/legal/<unknown>→ 404 — run under a build where the manifest is absent at build time (the CI shape). At minimum, assert the page module exports nogenerateStaticParams. - Ordering against the deletion card: that card deletes the whole route, which also closes this. If it merges first, name this key in its PR body and let the sweep close it. If this fixes first (recommended — the 500 is user-visible today and the deletion is a 70-minute sweep behind a human gate), the deletion card's table gains one line: the stub it removes is the fixed one.
Acceptance criteria
GET /legal/termson a build produced WITHOUTMOTIR_LEGAL_DOCUMENTSat build time, started WITH it, answers 308 tohttps://motir.co/legal/terms;GET /legal/no-such-docanswers 404. Both quoted in the PR body from a localnext build && next start.- The page module no longer exports
generateStaticParams, and a test asserts it (or asserts the route's rendering mode). - After deploy:
curl -sS -o /dev/null -w '%{http_code}' https://app.motir.co/legal/terms→308, pasted on this card. - Coverage: ≥90% on any file modified; the five files
vitest.config.tspins stay at their thresholds.
Resolution
open
Context refs
motir-core/app/(public)/legal/[slug]/page.tsx— the stub, and thegenerateStaticParamsthat mis-classifies itmotir-core/app/(public)/legal/layout.tsx:38·i18n/request.ts:6— thecookies()the static render meetsmotir-core/proxy.ts:66-84— the 308 that would mask this onceMOTIR_PUBLIC_SITE_URLis setmotir-core/lib/legal/documents.ts—legalDocumentSlugs()reads runtime configuration- MOTIR-4007 — introduced the stub · MOTIR-4102 — found it · MOTIR-4103 — deletes the route · MOTIR-3910 — the cutover whose 308 would hide it
Advisory dispositions
- Any
referenceadvisory naming MOTIR-4103 or MOTIR-3910: NOT an owed edge — both sit in other containers (core.mdgate 7), and neither must land before this fix; they are named because they change what "fixed" looks like, not because they gate it.
Discussion
No comments yet.
Adding to this discussion signs you in on app.motir.co and brings you back to this request.