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
**Moved (Task 2)** — `app/*` → `app/(frontend)/*`, unchanged in content:
`layout.tsx`, `page.tsx`, `globals.css`, `robots.ts`, `opengraph-image.tsx`,
`icon.png`, `apple-icon.png`, `rankings/`, `admissions/`, `compare/`,
`schools/`, `school/`, `api/`, `sitemaps/`, `sitemap.xml/`.
`layout.tsx`, `page.tsx`, `globals.css`, `rankings/`, `admissions/`,
`compare/`, `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:**
@@ -264,20 +276,25 @@ Write down the passing test count. It must be identical at Step 5.
```bash
cd nextjs-app/app && mkdir -p "(frontend)"
git mv layout.tsx page.tsx globals.css robots.ts opengraph-image.tsx \
icon.png apple-icon.png \
git mv layout.tsx page.tsx globals.css \
rankings admissions compare schools school api sitemaps sitemap.xml \
"(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
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
authoritative check: untracked files do not show in `git diff --stat`.
Expected: `(frontend)`, `apple-icon.png`, `icon.png`, `opengraph-image.tsx`,
`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**
@@ -304,13 +321,27 @@ import { GET } from '@/app/(frontend)/api/[...path]/route';
- [ ] **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
cd /Users/tudor/projects/school_compare && grep -rn "app/layout\|app/page\|@/app/" \
--include=*.ts --include=*.tsx --include=*.js --include=*.mjs \
cd /Users/tudor/projects/school_compare
# 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)"
# 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**
@@ -595,7 +626,7 @@ proxy's catch-all."
indexed and must not be reachable through search results.
**Files:**
- Modify: `nextjs-app/app/(frontend)/robots.ts`
- Modify: `nextjs-app/app/robots.ts`
- Modify: `nextjs-app/next.config.mjs`
- Modify: `nextjs-app/__tests__/app/nextConfig.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`:
```ts
import robots from '@/app/(frontend)/robots';
import robots from '@/app/robots';
describe('robots.txt', () => {
it('disallows the admin panel and the CMS API', () => {
@@ -651,7 +682,7 @@ rule exists.
- [ ] **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
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/app/(frontend)/blog/rss.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`
- Create: `nextjs-app/__tests__/app/blogMetadata.test.ts`
- Modify: `e2e/tests/journeys.spec.ts`
@@ -2123,7 +2154,7 @@ ${urls.map(({ loc, lastmod }) =>
- [ ] **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
sitemap: [absoluteUrl('/sitemap.xml'), absoluteUrl('/content-sitemap.xml')],
@@ -8,7 +8,7 @@
// environment provides — under jsdom this suite fails on import, not on an
// assertion.
import { NextRequest } from 'next/server';
import { GET } from '@/app/api/[...path]/route';
import { GET } from '@/app/(frontend)/api/[...path]/route';
function request(path: string) {
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 rankingsMetadata } from '@/app/rankings/page';
import { metadata as admissionsMetadata } from '@/app/admissions/page';
import { generateMetadata as compareMetadata } from '@/app/compare/page';
import { metadata as homeMetadata } from '@/app/(frontend)/page';
import { metadata as rankingsMetadata } from '@/app/(frontend)/rankings/page';
import { metadata as admissionsMetadata } from '@/app/(frontend)/admissions/page';
import { generateMetadata as compareMetadata } from '@/app/(frontend)/compare/page';
describe('canonical URLs', () => {
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.requireActual('@/lib/places'),
@@ -109,7 +109,7 @@ describe('dark-theme safety', () => {
* simply missed.
*/
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. */
const LEAFLET_POPUP_SURFACES = [
@@ -171,7 +171,7 @@ describe('third-party surfaces under themed text', () => {
*/
describe('destination tokens', () => {
const css = fs.readFileSync(
path.join(__dirname, '..', '..', 'app', 'globals.css'), 'utf8');
path.join(__dirname, '..', '..', 'app', '(frontend)', 'globals.css'), 'utf8');
const TOKENS = [
'--dest-sixthform', '--dest-sfcollege', '--dest-fecollege',
@@ -21,7 +21,7 @@ import {
import { nationalAveragesFixture } from './schoolFixtures';
// 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) {
return <ComparisonProvider>{ui}</ComparisonProvider>;
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.