From 24e114dee7110a2f5dfc25017b2c03da027543ac Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 21 Aug 2026 21:42:53 +0100 Subject: [PATCH] fix(places): stop the place titles doubling the brand Every place page shipped as 'Schools in Brentwood - Compare 27 Schools | schoolcompare | schoolcompare'. The root layout's title template appends '| schoolcompare' to any plain-string title, and all four place routes already carried the brand. W8 opted the other routes out with an absolute title; the place routes were written afterwards and did not inherit the lesson. ~2,600 titles affected, and the repetition pushed them past Google's truncation point, so the doubled brand displaced real words in the result. An e2e journey now asserts no title repeats the brand, across the static routes and a place page, so this cannot come back on a route added later. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj --- e2e/tests/journeys.spec.ts | 17 +++++++++++++++++ nextjs-app/__tests__/app/placeMetadata.test.ts | 14 +++++++++++++- nextjs-app/app/schools/[place]/[phase]/page.tsx | 2 +- nextjs-app/app/schools/[place]/page.tsx | 5 ++++- nextjs-app/app/schools/authority/[la]/page.tsx | 2 +- nextjs-app/app/schools/near/[outcode]/page.tsx | 2 +- 6 files changed, 37 insertions(+), 5 deletions(-) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 430fb5e..35b6049 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -1868,3 +1868,20 @@ test('phase variants are submitted in the places sitemap', async ({ page }) => { const xml = await (await page.request.get('/sitemaps/places-1.xml')).text(); expect(xml).toMatch(/\/schools\/[a-z0-9-]+\/primary { + // The root layout appends '| schoolcompare' to a plain-string title. Any + // route whose title already carries the brand must opt out with + // `absolute`, or it ships '... | schoolcompare | schoolcompare' — which is + // how ~2,600 place pages first went out. + const res = await page.request.get('/api/places'); + const { places } = await res.json(); + const town = places.find((p: { kind: string }) => p.kind === 'town'); + + for (const path of ['/', '/rankings', '/admissions', `/schools/${town.slug}`]) { + await page.goto(path); + const title = await page.title(); + const brands = (title.match(/schoolcompare/gi) ?? []).length; + expect(brands, `${path} repeats the brand: ${title}`).toBeLessThanOrEqual(1); + } +}); diff --git a/nextjs-app/__tests__/app/placeMetadata.test.ts b/nextjs-app/__tests__/app/placeMetadata.test.ts index 67286db..7c9f27e 100644 --- a/nextjs-app/__tests__/app/placeMetadata.test.ts +++ b/nextjs-app/__tests__/app/placeMetadata.test.ts @@ -14,7 +14,7 @@ jest.mock('@/lib/places', () => ({ describe('place page metadata', () => { it('titles the page the way the place is searched', async () => { const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) }); - expect(m.title).toMatch(/schools in brentwood/i); + expect((m.title as { absolute: string }).absolute).toMatch(/schools in brentwood/i); }); it('canonicalises to its own path on the www host', async () => { @@ -23,6 +23,18 @@ describe('place page metadata', () => { .toBe('https://www.schoolcompare.co.uk/schools/brentwood'); }); + it('opts out of the layout template, which would double the brand', () => { + // The root layout appends '| schoolcompare' to a plain string title, and + // these titles already carry it — every place page shipped reading + // '... | schoolcompare | schoolcompare' until this was made absolute. + return placeMeta({ params: Promise.resolve({ place: 'brentwood' }) }) + .then((m) => { + expect(typeof m.title).toBe('object'); + expect((m.title as { absolute: string }).absolute) + .not.toMatch(/schoolcompare.*schoolcompare/); + }); + }); + it('an unknown place gets a not-found title rather than inventing one', async () => { const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) }); expect(m.title).toMatch(/not found/i); diff --git a/nextjs-app/app/schools/[place]/[phase]/page.tsx b/nextjs-app/app/schools/[place]/[phase]/page.tsx index be97d0f..b63b80b 100644 --- a/nextjs-app/app/schools/[place]/[phase]/page.tsx +++ b/nextjs-app/app/schools/[place]/[phase]/page.tsx @@ -37,7 +37,7 @@ export async function generateMetadata({ params }: Props): Promise { const word = phase === 'secondary' ? 'Secondary' : 'Primary'; const { name } = detail.place; return { - title: `${word} Schools in ${name} — Ranked | schoolcompare`, + title: { absolute: `${word} Schools in ${name} — Ranked | schoolcompare` }, description: `Every ${phase} school in ${name} ranked by results, with Ofsted grades and ` + `the local average against England.`, diff --git a/nextjs-app/app/schools/[place]/page.tsx b/nextjs-app/app/schools/[place]/page.tsx index 8fe0111..c2007b3 100644 --- a/nextjs-app/app/schools/[place]/page.tsx +++ b/nextjs-app/app/schools/[place]/page.tsx @@ -53,7 +53,10 @@ export async function generateMetadata({ params }: Props): Promise { const { name, count } = detail.place; return { - title: `Schools in ${name} — Compare ${count} Schools | schoolcompare`, + // absolute: the root layout's template appends '| schoolcompare' to a + // plain string, and this title already carries it. Without this every + // place title read '... | schoolcompare | schoolcompare'. + title: { absolute: `Schools in ${name} — Compare ${count} Schools | schoolcompare` }, description: `Every school in ${name} ranked by SATs and GCSE results, with Ofsted grades, ` + `the local average against England, and how close you had to live to get a place.`, diff --git a/nextjs-app/app/schools/authority/[la]/page.tsx b/nextjs-app/app/schools/authority/[la]/page.tsx index 7f1aac7..f9b4c3e 100644 --- a/nextjs-app/app/schools/authority/[la]/page.tsx +++ b/nextjs-app/app/schools/authority/[la]/page.tsx @@ -43,7 +43,7 @@ export async function generateMetadata({ params }: Props): Promise { const { name, count } = detail.place; return { - title: `Schools in ${name} — Local Authority | schoolcompare`, + title: { absolute: `Schools in ${name} — Local Authority | schoolcompare` }, description: `All ${count} schools in the ${name} local authority, ranked by SATs and GCSE ` + `results, with Ofsted grades and the authority average against England.`, diff --git a/nextjs-app/app/schools/near/[outcode]/page.tsx b/nextjs-app/app/schools/near/[outcode]/page.tsx index 9a43aa2..e08d471 100644 --- a/nextjs-app/app/schools/near/[outcode]/page.tsx +++ b/nextjs-app/app/schools/near/[outcode]/page.tsx @@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Props): Promise { const { name, count } = detail.place; return { - title: `Schools near ${name} | schoolcompare`, + title: { absolute: `Schools near ${name} | schoolcompare` }, description: `${count} schools in the ${name} postcode district, ranked by results, with ` + `Ofsted grades and how close you had to live to get a place.`, -- 2.54.0