Skip to content

Migrate Pages Router to App Router - #267

Draft
cooperability wants to merge 2 commits into
mainfrom
feat/app-router-migration
Draft

Migrate Pages Router to App Router#267
cooperability wants to merge 2 commits into
mainfrom
feat/app-router-migration

Conversation

@cooperability

@cooperability cooperability commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Closes the App Router item in the README modernization TODO, plus the adjacent ones it turned out to depend on.

What changed

Routes move from src/pages/** to src/app/**. src/pages is gone entirely. Every page is a server component; interactivity is pushed down into named 'use client' leaves (quote-box.tsx, resources-list.tsx, one *-client.tsx per applet) rather than marking whole routes client-side.

Net -1633 lines across 59 files, mostly because getStaticProps/getServerSideProps plumbing and the getLayout pattern collapse into the file conventions.

The nuances, which is the actually interesting part

These are written up in full in README.md under App Router migration notes. The ones that cost real debugging time:

Metadata inheritance replaces, it does not deep-merge. Setting icons: { apple: … } on a page silently drops the layout's icon. That is how the favicon vanished from the three applet routes mid-migration — and the check that "found" it again was reading a stale dev server on a port I thought I'd killed. Every metadata claim in this PR was re-verified against a fresh production build on an unused port.

openGraph.title has its own resolution chain and never derives from title. A title.template on the root layout looks like the fix but only applies to children that set their own openGraph.title; children setting just title inherit the parent's resolved default verbatim. Five routes were serving the bare site title as their share card before I set each one explicitly. Worth noting main had the same bug hidden — it emitted <meta name="og:title">, and OG scrapers only read property, so those tags were being ignored outright.

generateStaticParams is not getStaticPaths: { fallback: false }. It defaults to dynamicParams: true, so an unknown slug renders on demand and 500s where it used to 404. Needs an explicit export const dynamicParams = false.

next/dynamic with ssr: false is illegal in a server component. Hence the server page.tsx (which owns metadata) plus thin client wrapper per applet, rather than one merged client route.

force-dynamic is load-bearing, and its absence is silent. Without it the homepage prerenders and the per-request quote freezes at build time — no warning, just a page that stops changing.

next-sitemap never reads app-build-manifest. A force-dynamic route appears in neither manifest it does read, so / dropped out of the sitemap. Restored via additionalPaths. public/sitemap*.xml is gitignored, so this class of regression is structurally invisible to code review — the reason it's a README note and not just a fix.

next-themes short-circuits a nested provider to a Fragment, so the inner one's props had been dead code. Collapsing to one provider made NEXT_PUBLIC_AXE_FORCE_THEME take effect for the first time — expect yarn access numbers to move, and treat the new figures as the baseline.

Verified against a production build

yarn typecheck and yarn jest --ci clean. Per-route checks against next start:

Check Result
og:title distinct per route 7/7 routes
favicon + apple-mobile-web-app-capable 7/7 routes
unknown resource slug 404 (was 500 mid-migration)
sitemap includes / yes, 12 URLs
working tree after a clean build clean

Behaviour that intentionally changed

  • /api/hello is deleted — unused scaffold; a replacement would now be app/api/*/route.ts.
  • Canonical host standardises on www, matching next-sitemap.config.js. The old homepage canonical pointed at the apex, and the JSON-LD disagreed with both. If the apex is what you actually want, that's one line in metadataBase plus the sitemap config — flagged as a TODO.
  • Homepage gains a Mandelbrot Explorer CTA (carried from uncommitted local work).

Build system

next build is pinned to --webpack. Next 16 defaults to Turbopack, which cannot resolve next/package.json under Yarn PnP — plain next build fails outright. This unblocks the build without pre-empting the pnpm migration the README already argues for.

yarn lint is broken on main and stays broken here: an ESLint/typescript-eslint major mismatch throws Class extends value undefined. Out of scope, left as a TODO, but it means jsx-a11y did not run on this diff — worth knowing when reviewing the client boundaries.

Repo hygiene folded in

All of this was dirtying the working tree on every build or cross-OS install, which is what made the migration diff hard to read in the first place:

  • Untracked tsconfig.tsbuildinfo, accessibility-reports/ and public/sw.js — the latter two were already gitignored yet tracked, so the ignore rule was doing nothing.
  • Ignored platform-native .yarn/cache archives. A Windows yarn install had staged win32 sharp/swc/unrs-resolver binaries over the linux ones Vercel builds against (~49 MB each way). Committing that set would likely have broken the next deploy's image optimization and compilation, so it is deliberately not in this PR.
  • next-env.d.ts was the mirror-image problem — ignored yet tracked, and rewritten by every build. Now tracked deliberately, because yarn typecheck needs its CSS-module and image declarations.

Known gaps, filed not fixed

  • Serwist precache paths still point at Pages Router chunk names; the service worker registers but precaches nothing useful.
  • prop-types removal deferred — dropping a dep regenerates .pnp.cjs/yarn.lock and writes new tracked .yarn/cache zips, which belongs in its own PR.
  • No OG images (@vercel/og) yet; og:title is correct but there's no card art.

Draft rather than ready-for-review: the yarn access baseline needs re-measuring now that theming actually works, and I'd rather you sanity-check the www canonical decision before this merges.

Made with Cursor


Update: the toolchain repair (second commit)

The first commit noted that yarn lint was broken on main and stayed broken, so jsx-a11y never ran on the migration diff. Fixing that turned into one causal chain worth reading in order, because each step uncovered the next.

1. ESLint 10 is ahead of this stack

The crash (TypeError: Class extends value undefined) looked like one stale package. It wasn't. Surveying every plugin's peer range:

Plugin Latest Max ESLint
eslint-plugin-react 7.37.5 ^9.7
eslint-plugin-jsx-a11y 6.10.2 ^9
eslint-plugin-import 2.32.0 ^9
eslint-plugin-react-hooks 7.1.1 ^10
typescript-eslint 8.68.0 ^10

Three of the plugins eslint-config-next pulls have no ESLint 10 release at all — including jsx-a11y, the one this project most cares about. So eslint moves to 9.39.5. That is a downgrade, and it is not weakening the gate: the alternative was no gate at all.

Two related things fell out:

  • FlatCompat is obsolete here. eslint-config-next 16 ships native flat config at eslint-config-next/core-web-vitals; wrapping it in FlatCompat throws Converting circular structure to JSON. Dropping the shim removed @eslint/eslintrc and @eslint/compat (the latter was in package.json but never imported).
  • React version detection is pinned. eslint-config-next sets react: { version: 'detect' }, and detection calls an API v10 removed. Reading the installed version directly is immune to that and faster.

eslint-config-prettier also moved to the end of the array, where it can actually switch off the stylistic rules of the configs before it.

2. yarn dev was broken outright

Restoring lint exposed this: yarn dev fails completely under Yarn PnP, because Next 16 defaults to Turbopack and Turbopack can't resolve next/package.json. This is the same root cause the first commit pinned --webpack for — but only build had been fixed. dev and analyze now pin it too. analyze needed it regardless, since @next/bundle-analyzer is a webpack plugin Turbopack ignores entirely.

3. Which surfaced a latent build error

Getting dev running generated .next/dev/types for the first time, which immediately failed typecheck: layout.tsx exported siteTitle. Next type-checks route files against a fixed set of permitted exports. Nothing could see this before, because the types that enforce it were never generated.

Worth knowing as a footgun: yarn typecheck is stricter after yarn dev has run, because tsconfig.json includes .next/dev/types/**.

4. Five real render bugs, fixed at the pattern level

With lint running, react-hooks/set-state-in-effect flagged five genuine extra render passes. None are suppressed:

Where Was Now
ThemeSwitch, ActiveIcon useState(false) + useEffect(setMounted) new useHydrated() on useSyncExternalStore — no effect, no second pass
useResponsive width seeded from an effect reads the viewport through useSyncExternalStore, debounce preserved
OpioidConverter equivalences mirrored into state useMemo; every dose change had painted the previous total first
PromptComposer edit buffer re-synced in an effect adjusted during render; also removes a flash of the empty-state placeholder

18 new tests, each proven able to fail: mutating Math.pow(dose, 2) to dose * 2 and the resize debounce from 100ms to 900ms turned exactly the three intended assertions red, then restored green. The dosage arithmetic in particular is now locked down — it's the highest-consequence code in the repo and had no coverage.

5. Other fixes in the same pass

Security headers on every route (closes a README TODO): X-Content-Type-Options, Referrer-Policy, X-Frame-Options, Permissions-Policy, HSTS. Verified present on both root and nested routes.

  • HSTS ships without preload — that puts the apex on browser preload lists and is impractical to reverse. It should be a decision, not a side effect.
  • CSP ships as Content-Security-Policy-Report-Only, and still needs promoting. It cannot be enforced as written: next-themes and Next's bootstrap both inject inline <script>, so a real policy needs a per-request nonce — which forces dynamic rendering on every route. Check a preview deploy's console, then decide if that trade is worth it.

The service worker was precaching nothing. build-sw.mjs globbed the .next filesystem tree, so entries came out as static/chunks/x.js and the browser requested /static/chunks/x.js. Confirmed 404 by direct request. It also swept in .next/server and .next/cache, neither reachable over HTTP. Now 42 entries under /_next/static/, each verified 200.

prop-types removed. The blocker recorded against it in the README didn't exist: YARN_CACHE_FOLDER is set in vercel.json and in local shells, so Yarn writes outside the tracked .yarn/cache and dependency changes produce no cache churn. (Corollary worth noting: zero-install is already not in effect for this repo.)

Verification

lint · typecheck · jest (25 tests) · build · yarn install --immutable all exit 0. Sitemap still 12 URLs. Working tree stays clean across a full build.

Not verified, and I'd rather say so: yarn access now gets past lint but can't complete here — the agent sandbox has no Chrome binary. Please run it locally. Treat the result as a new baseline rather than a regression, because collapsing the nested ThemeProvider in the first commit made NEXT_PUBLIC_AXE_FORCE_THEME effective for the first time.

Still open

  • Promote the CSP out of Report-Only, or record why not.
  • Precache public/ assets; the manifest is scoped to .next/static, and there's still no offline fallback route.
  • Confirm the www vs apex canonical decision.
  • No loading.tsx streaming boundaries yet — deliberately skipped, since all these routes are statically generated and the win is small.

…TODOs

Routes move from `src/pages/**` to `src/app/**`. Every page is a server
component; interactivity is pushed into named `'use client'` leaves rather than
marking whole routes client-side.

Behaviour intended to change:
- `/api/hello` is deleted (was an unused scaffold). A replacement would now be
  an `app/api/*/route.ts` Route Handler.
- The homepage gains a Mandelbrot Explorer CTA beside the Prompt Composer one.
- Canonical host standardises on `https://www.cooperability.com`, matching
  `next-sitemap.config.js`. The old homepage canonical pointed at the apex.
- `yarn access` scores will move: collapsing two nested `next-themes` providers
  into one made `NEXT_PUBLIC_AXE_FORCE_THEME` effective for the first time.

Behaviour deliberately preserved, and verified against a production build:
per-route `og:title`, favicon and `apple-mobile-web-app-capable` on all seven
routes; unknown resource slugs 404 rather than 500; `/` stays in the sitemap
(12 URLs); the homepage quote still varies per request.

`next build` is pinned to `--webpack`: Next 16 defaults to Turbopack, which
cannot resolve `next/package.json` under Yarn PnP. This unblocks the build
without pre-empting the pnpm migration.

Also folded in, since all of it was making the working tree dirty on every
build or cross-OS install:
- Untrack `tsconfig.tsbuildinfo`, `accessibility-reports/` and `public/sw.js`
  (the latter two were already gitignored yet tracked), and ignore
  platform-native `.yarn/cache` archives. A Windows `yarn install` had staged
  win32 `sharp`/`swc` binaries over the linux ones Vercel builds against.
- Track `next-env.d.ts` deliberately instead of ignoring-yet-tracking it;
  `yarn typecheck` needs its CSS-module and image declarations.
- Documentation WIP for `docs/Tooling.md` and the Prompt Composer README.

Migration nuances that cost real debugging time are written up in README.md
under "App Router migration notes" rather than left in this message.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portfolio Ready Ready Preview Aug 26, 2026 10:38am

cooperability added a commit that referenced this pull request Aug 26, 2026
Fourth single-variable attempt at the failing preview deploy, and the first
one aimed at the builder rather than the package manager.

The App Router branch (#267) explicitly pins `next build --webpack`, which is
a deliberate opt-out of the Next 16 default. The most likely reason to add
that flag is that Turbopack does not build this project on Vercel -- which
would mean the failing deploys here have nothing to do with pnpm at all, and
everything to do with this branch restoring the default builder while removing
the `ls -la .yarn` debug probes from the same script.

If this deploy goes green, the builder was the cause and the pnpm migration
was never implicated. That also revises the recommendation in
docs/PNPM-MIGRATION.md section 8: Turbopack's 2.4x faster build is not
available here until whatever breaks it on Vercel is understood.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RtsQWaEaAe4iVnSoUCDfY
cooperability added a commit that referenced this pull request Aug 26, 2026
Pinning --webpack did not fix the preview deploy, so the builder is not
implicated and there is no reason to give up Turbopack's 2.4x faster build
(measured, both sides, in docs/PNPM-MIGRATION.md section 8).

Parking the Vercel failure here rather than continuing to guess. Four
hypotheses were each tested by pushing them alone, and each was wrong:

  1. corepack not enabled            -> restored ENABLE_EXPERIMENTAL_COREPACK=1
  2. engines.node semver range       -> back to the "22.x" major selector
  3. pnpm-workspace packages: [.]    -> removed the monorepo signal
  4. Turbopack failing on Vercel     -> pinned --webpack

Changes 1-3 are correct regardless and are kept; 4 is reverted here.

What is known: main and PR #267 both deploy successfully, and every commit on
this branch fails, so the cause is on this branch. GitHub Actions runs the
same install and the same production build on ubuntu and passes in ~60s, so
it is specific to the Vercel environment rather than to pnpm or the build.

What is needed: the build log, which cannot be read from here -- there is no
Vercel CLI and no token in this environment. One command answers it:

    npx vercel login && npx vercel inspect <deployment-url> --logs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RtsQWaEaAe4iVnSoUCDfY
The App Router migration landed without a working lint gate. Restoring it
turned out to also mean restoring the dev server, and both then surfaced real
defects — so this is one causal chain rather than three unrelated changes.

`yarn lint` ran zero rules; it crashed. ESLint 10 is ahead of this stack:
eslint-plugin-react, eslint-plugin-jsx-a11y and eslint-plugin-import have no
ESLint 10 release at all, so there was nothing to upgrade to. Pinned to 9.39.5,
which every plugin supports — not a weakening, since the alternative was no
gate. `eslint-config-next` 16 ships a native flat config, so the FlatCompat
shim is gone along with `@eslint/eslintrc` and the never-imported
`@eslint/compat`, and React version detection is pinned rather than
autodetected (detection calls an API v10 removed).

`yarn dev` was broken outright, for the reason `next build` already had a
workaround for: Turbopack cannot resolve `next/package.json` under Yarn PnP,
and only `build` had been pinned to `--webpack`. `dev` and `analyze` now pin it
too. `analyze` needed it regardless — `@next/bundle-analyzer` is a webpack
plugin that Turbopack ignores.

Fixing `dev` generated `.next/dev/types` for the first time, which immediately
caught `layout.tsx` exporting `siteTitle`. Next type-checks route files against
a fixed set of allowed exports, so that had been a latent build error nothing
could see.

With lint running, five `react-hooks/set-state-in-effect` errors surfaced. All
are genuine extra render passes, and all are fixed at the pattern level rather
than suppressed:

- `useHydrated` (new, built on `useSyncExternalStore`) replaces the
  `useState(false)` + `useEffect(() => setMounted(true))` idiom in ThemeSwitch
  and ActiveIcon, which cost a second render on every mount.
- `useResponsive` reads the viewport through `useSyncExternalStore` rather than
  seeding it from an effect, keeping the debounced resize subscription.
- OpioidConverter derives both equivalences with `useMemo`. They had been
  mirrored into state, so every dose change painted the previous total first.
- PromptComposer adjusts its edit buffer during render instead of re-syncing in
  an effect, which also removes a flash of the empty-state placeholder.

Covered by 18 new tests over the dosage arithmetic and the viewport hook, each
proven able to fail by mutating the code under test and confirming red.

Also in this pass:

- Security headers on every route: `X-Content-Type-Options`, `Referrer-Policy`,
  `X-Frame-Options`, `Permissions-Policy` and HSTS. No `preload` on HSTS, which
  is impractical to reverse. The CSP ships as `Content-Security-Policy-Report-
  Only`: next-themes and Next's own bootstrap both inject inline script, so
  enforcing it needs a per-request nonce, which would force dynamic rendering
  on every route.
- The service worker precached nothing usable. It globbed `.next` filesystem
  paths, so entries like `static/chunks/x.js` resolved to `/static/chunks/x.js`
  and 404'd, and it also swept in `.next/server` and `.next/cache`, neither of
  which is reachable over HTTP. Now 42 entries under `/_next/static/`, each
  verified to return 200.
- Remove `prop-types`; it was never imported. The blocker recorded against it
  did not exist: `YARN_CACHE_FOLDER` is set in `vercel.json` and locally, so
  Yarn writes outside the tracked `.yarn/cache` and dependency changes produce
  no cache churn.

`yarn access` is unblocked but not re-measured — it now gets past lint and
fails only for want of a Chrome binary in the sandbox this was fixed in.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant