refactor(app): move site routes into a (frontend) route group

Payload's admin panel ships its own root layout rendering html/body.
Next allows multiple root layouts only when no app/layout.tsx exists, so
the site's routes move into their own group. Route groups are invisible
to routing: every public URL is unchanged, verified against the build's
route table.

The metadata file conventions deliberately stay at the app/ root. Moving
them into the group renamed /icon.png to /icon-4usi79.png (likewise
apple-icon and opengraph-image) and dropped /robots.txt altogether,
which would have broken the /icon.png cache-control rule, the
outputFileTracingIncludes entry for the share card, and robots.txt.

darkThemeSafety reads app/globals.css off disk rather than importing it,
so it needed its own path fix — a grep for import specifiers misses it,
and it fails as an unrunnable suite rather than a failed assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
This commit is contained in:
TudorandClaude Opus 5 committed 2026-09-02 16:11:48 +01:00
1 parent eb648f3f76
commit 2437ffce42
21 files changed
+57 -26

No files matched your search

@@ -83,9 +83,21 @@ route to `/cms-api` is sufficient and complete. Do not add the exclusion.
## File Structure ## File Structure
**Moved (Task 2)** — `app/*` → `app/(frontend)/*`, unchanged in content: **Moved (Task 2)** — `app/*` → `app/(frontend)/*`, unchanged in content:
`layout.tsx`, `page.tsx`, `globals.css`, `robots.ts`, `opengraph-image.tsx`, `layout.tsx`, `page.tsx`, `globals.css`, `rankings/`, `admissions/`,
`icon.png`, `apple-icon.png`, `rankings/`, `admissions/`, `compare/`, `compare/`, `schools/`, `school/`, `api/`, `sitemaps/`, `sitemap.xml/`.
`schools/`, `school/`, `api/`, `sitemaps/`, `sitemap.xml/`.
**Deliberately NOT moved — they stay at the `app/` root:** `robots.ts`,
`opengraph-image.tsx`, `icon.png`, `apple-icon.png`.
Next.js metadata file conventions only produce stable root URLs at the `app/`
root. Inside a route group they are treated as segment-scoped: verified during
execution, moving them into `(frontend)` renamed `/icon.png` to
`/icon-4usi79.png`, `/apple-icon.png` to `/apple-icon-4usi79.png`,
`/opengraph-image` to `/opengraph-image-4usi79`, and dropped `/robots.txt`
entirely. That would have broken the `/icon.png` cache-control rule and the
`outputFileTracingIncludes['/opengraph-image']` entry in `next.config.mjs`,
and silently removed the site's robots.txt. Route handlers (`sitemap.xml/`,
`sitemaps/`, `api/`) are unaffected and move normally.
**Created:** **Created:**
@@ -264,20 +276,25 @@ Write down the passing test count. It must be identical at Step 5.
```bash ```bash
cd nextjs-app/app && mkdir -p "(frontend)" cd nextjs-app/app && mkdir -p "(frontend)"
git mv layout.tsx page.tsx globals.css robots.ts opengraph-image.tsx \ git mv layout.tsx page.tsx globals.css \
icon.png apple-icon.png \
rankings admissions compare schools school api sitemaps sitemap.xml \ rankings admissions compare schools school api sitemaps sitemap.xml \
"(frontend)/" "(frontend)/"
``` ```
Verify nothing is left behind — `app/` should now contain only `(frontend)`: `robots.ts`, `opengraph-image.tsx`, `icon.png` and `apple-icon.png` stay at the
`app/` root — see "Deliberately NOT moved" in the File Structure section above.
Moving them silently changes their URLs and drops robots.txt.
Verify the split — `app/` should now contain `(frontend)` plus exactly the four
metadata conventions:
```bash ```bash
cd /Users/tudor/projects/school_compare/nextjs-app && ls app cd /Users/tudor/projects/school_compare/nextjs-app && ls -A app
``` ```
If anything else appears, move it too. `git status --short` is the Expected: `(frontend)`, `apple-icon.png`, `icon.png`, `opengraph-image.tsx`,
authoritative check: untracked files do not show in `git diff --stat`. `robots.ts`. If a route directory is still there, move it. `git status --short`
is the authoritative check: untracked files do not show in `git diff --stat`.
- [ ] **Step 3: Update the four test imports** - [ ] **Step 3: Update the four test imports**
@@ -304,13 +321,27 @@ import { GET } from '@/app/(frontend)/api/[...path]/route';
- [ ] **Step 4: Find any other references to the old paths** - [ ] **Step 4: Find any other references to the old paths**
Two searches, because import specifiers are not the only way a file names a
path. Quote the `--include` globs or zsh expands them.
```bash ```bash
cd /Users/tudor/projects/school_compare && grep -rn "app/layout\|app/page\|@/app/" \ cd /Users/tudor/projects/school_compare
--include=*.ts --include=*.tsx --include=*.js --include=*.mjs \ # 1. Import specifiers
grep -rn "app/layout\|app/page\|@/app/" \
--include="*.ts" --include="*.tsx" --include="*.js" --include="*.mjs" \
nextjs-app --exclude-dir=node_modules --exclude-dir=.next | grep -v "app/(frontend)" nextjs-app --exclude-dir=node_modules --exclude-dir=.next | grep -v "app/(frontend)"
# 2. Filesystem paths — readFileSync/path.join targets, which search 1 misses.
# __tests__/components/darkThemeSafety.test.ts reads app/globals.css this way
# and fails to *run* when the path is stale, so it shows as a failed suite
# rather than a failed assertion.
grep -rn "'app'" nextjs-app/__tests__ --include="*.ts" --include="*.tsx" \
| grep -v "(frontend)"
``` ```
Expected: no output. Fix anything that appears. Expected: no output from either. Fix anything that appears — note that
`renderSchoolDetail.tsx` names `app/layout.tsx` in a comment, which should be
updated for accuracy even though nothing breaks.
- [ ] **Step 5: Verify tests, types and build** - [ ] **Step 5: Verify tests, types and build**
@@ -595,7 +626,7 @@ proxy's catch-all."
indexed and must not be reachable through search results. indexed and must not be reachable through search results.
**Files:** **Files:**
- Modify: `nextjs-app/app/(frontend)/robots.ts` - Modify: `nextjs-app/app/robots.ts`
- Modify: `nextjs-app/next.config.mjs` - Modify: `nextjs-app/next.config.mjs`
- Modify: `nextjs-app/__tests__/app/nextConfig.test.ts` - Modify: `nextjs-app/__tests__/app/nextConfig.test.ts`
- Create: `nextjs-app/__tests__/app/robots.test.ts` - Create: `nextjs-app/__tests__/app/robots.test.ts`
@@ -609,7 +640,7 @@ indexed and must not be reachable through search results.
Create `nextjs-app/__tests__/app/robots.test.ts`: Create `nextjs-app/__tests__/app/robots.test.ts`:
```ts ```ts
import robots from '@/app/(frontend)/robots'; import robots from '@/app/robots';
describe('robots.txt', () => { describe('robots.txt', () => {
it('disallows the admin panel and the CMS API', () => { it('disallows the admin panel and the CMS API', () => {
@@ -651,7 +682,7 @@ rule exists.
- [ ] **Step 3: Add the robots disallow entries** - [ ] **Step 3: Add the robots disallow entries**
In `nextjs-app/app/(frontend)/robots.ts`, change the `disallow` array to: In `nextjs-app/app/robots.ts`, change the `disallow` array to:
```ts ```ts
disallow: ['/api/', '/_next/', '/admin/', '/cms-api/'], disallow: ['/api/', '/_next/', '/admin/', '/cms-api/'],
@@ -1527,7 +1558,7 @@ without a webhook."
- Create: `nextjs-app/components/blog/CalloutBlock.tsx` + `.module.css` - Create: `nextjs-app/components/blog/CalloutBlock.tsx` + `.module.css`
- Create: `nextjs-app/app/(frontend)/blog/rss.xml/route.ts` - Create: `nextjs-app/app/(frontend)/blog/rss.xml/route.ts`
- Create: `nextjs-app/app/(frontend)/content-sitemap.xml/route.ts` - Create: `nextjs-app/app/(frontend)/content-sitemap.xml/route.ts`
- Modify: `nextjs-app/app/(frontend)/robots.ts` - Modify: `nextjs-app/app/robots.ts`
- Modify: `nextjs-app/lib/jsonld.ts` - Modify: `nextjs-app/lib/jsonld.ts`
- Create: `nextjs-app/__tests__/app/blogMetadata.test.ts` - Create: `nextjs-app/__tests__/app/blogMetadata.test.ts`
- Modify: `e2e/tests/journeys.spec.ts` - Modify: `e2e/tests/journeys.spec.ts`
@@ -2123,7 +2154,7 @@ ${urls.map(({ loc, lastmod }) =>
- [ ] **Step 9: List both sitemaps in robots.txt** - [ ] **Step 9: List both sitemaps in robots.txt**
In `nextjs-app/app/(frontend)/robots.ts`, change the `sitemap` key to: In `nextjs-app/app/robots.ts`, change the `sitemap` key to:
```ts ```ts
sitemap: [absoluteUrl('/sitemap.xml'), absoluteUrl('/content-sitemap.xml')], sitemap: [absoluteUrl('/sitemap.xml'), absoluteUrl('/content-sitemap.xml')],
@@ -8,7 +8,7 @@
// environment provides — under jsdom this suite fails on import, not on an // environment provides — under jsdom this suite fails on import, not on an
// assertion. // assertion.
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { GET } from '@/app/api/[...path]/route'; import { GET } from '@/app/(frontend)/api/[...path]/route';
function request(path: string) { function request(path: string) {
return new NextRequest(`http://localhost:3000/api/${path}`); return new NextRequest(`http://localhost:3000/api/${path}`);
+4 -4
View File
@@ -1,7 +1,7 @@
import { metadata as homeMetadata } from '@/app/page'; import { metadata as homeMetadata } from '@/app/(frontend)/page';
import { metadata as rankingsMetadata } from '@/app/rankings/page'; import { metadata as rankingsMetadata } from '@/app/(frontend)/rankings/page';
import { metadata as admissionsMetadata } from '@/app/admissions/page'; import { metadata as admissionsMetadata } from '@/app/(frontend)/admissions/page';
import { generateMetadata as compareMetadata } from '@/app/compare/page'; import { generateMetadata as compareMetadata } from '@/app/(frontend)/compare/page';
describe('canonical URLs', () => { describe('canonical URLs', () => {
it('the homepage canonicalises to the bare root', () => { it('the homepage canonicalises to the bare root', () => {
@@ -1,4 +1,4 @@
import { generateMetadata as placeMeta } from '@/app/schools/[place]/page'; import { generateMetadata as placeMeta } from '@/app/(frontend)/schools/[place]/page';
jest.mock('@/lib/places', () => ({ jest.mock('@/lib/places', () => ({
...jest.requireActual('@/lib/places'), ...jest.requireActual('@/lib/places'),
@@ -109,7 +109,7 @@ describe('dark-theme safety', () => {
* simply missed. * simply missed.
*/ */
describe('third-party surfaces under themed text', () => { describe('third-party surfaces under themed text', () => {
const GLOBALS = path.join(__dirname, '..', '..', 'app', 'globals.css'); const GLOBALS = path.join(__dirname, '..', '..', 'app', '(frontend)', 'globals.css');
/** Leaflet surfaces our own code writes token-coloured text onto. */ /** Leaflet surfaces our own code writes token-coloured text onto. */
const LEAFLET_POPUP_SURFACES = [ const LEAFLET_POPUP_SURFACES = [
@@ -171,7 +171,7 @@ describe('third-party surfaces under themed text', () => {
*/ */
describe('destination tokens', () => { describe('destination tokens', () => {
const css = fs.readFileSync( const css = fs.readFileSync(
path.join(__dirname, '..', '..', 'app', 'globals.css'), 'utf8'); path.join(__dirname, '..', '..', 'app', '(frontend)', 'globals.css'), 'utf8');
const TOKENS = [ const TOKENS = [
'--dest-sixthform', '--dest-sfcollege', '--dest-fecollege', '--dest-sixthform', '--dest-sfcollege', '--dest-fecollege',
@@ -21,7 +21,7 @@ import {
import { nationalAveragesFixture } from './schoolFixtures'; import { nationalAveragesFixture } from './schoolFixtures';
// The shell calls useComparison(), which throws outside the provider. In the // The shell calls useComparison(), which throws outside the provider. In the
// app this wrapper comes from app/layout.tsx. // app this wrapper comes from app/(frontend)/layout.tsx.
function withProviders(ui: ReactNode) { function withProviders(ui: ReactNode) {
return <ComparisonProvider>{ui}</ComparisonProvider>; return <ComparisonProvider>{ui}</ComparisonProvider>;
} }
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.