SEO, metadata and error pages
How every route publishes metadata and structured data through one contract, and how missing content reaches the 404 and 500 pages.
Page metadata
Every page's <title>, <meta name="description"> and social tags come from
two layers: static defaults on the root layout, and a per-route
generateMetadata that overrides them from the fetched content. Both use the
Next Metadata API — a plain object the framework serialises into <head>;
there is no hand-written <Head> anywhere in the app.
The static layer: the root layout
apps/web/src/app/[locale]/layout.tsx (the root layout — every route lives
under the [locale] segment, so there is no app/layout.tsx) exports a static
metadata object derived
entirely from club.config.ts (the first real consumer of the
@v4/template-config layer — change the config, never this file):
export const metadata: Metadata = {
title: {
default: clubConfig.identity.name,
template: clubConfig.identity.titleTemplate, // "%s | EuroLeague Basketball" — Next's own %s format
},
description: `${clubConfig.identity.name} — assembled from @v4/ghost-core`,
metadataBase: new URL(clubConfig.seo.baseUrl),
robots: clubConfig.seo.allowRobots ? undefined : { index: false, follow: false },
};
Three of these are load-bearing for every page below:
title.template—"%s | EuroLeague Basketball"(identity.titleTemplateinclub.config.ts). When a route returns a plain stringtitle, Next substitutes it into the%sslot, so an article titled "Match report" becomesMatch report | EuroLeague Basketball.defineClubConfigrejects atitleTemplatewith no%sslot.title.default— the club name ("EuroLeague Basketball"), used verbatim when a route returns no title (see the empty-metadata fallback below).metadataBase—clubConfig.seo.baseUrl. Relative URLs in any route's metadata resolve against it; it is also what letsrobotsand canonical/OG URLs be absolute. It is read fromNEXT_PUBLIC_SITE_BASE_URL(env.public, the name euro-league already uses), defaulting tohttp://localhost:3000for local dev and CI only.defineClubConfigfails the build when crawling is allowed (NEXT_PUBLIC_ROBOTS_ALLOW=true) on a local address, and any production build still on one warns by name — set it on every deployed environment (see the environment variables reference).robots— gated onclubConfig.seo.allowRobots. The safe default isfalse, which emits{ index: false, follow: false }. Here it readsNEXT_PUBLIC_ROBOTS_ALLOW === "true", so every environment without that variable is noindex. (For crawl-directive files —robots.txt, sitemaps — see the Sitemaps guide.)
The per-route layer: generateMetadata
Each route in @v4/content-app, @v4/video-app and @v4/basketball-app is a
factory that returns a Page and a generateMetadata (and, where the route
prerenders, generateStaticParams). The generated app shim (pnpm compose
output) re-exports them. The news shim, for example — news/[...path] serves
articles, competition news and listings by slug, wrapping
createNewsArticleRoute for the article case:
// apps/web/src/app/[locale]/news/[...path]/page.tsx — GENERATED, do not edit
const route = createNewsPathRoute({
client: contentClient,
buildClient: deliveryContentClient,
locale: clubConfig.locales.default,
});
export default route.Page;
export const generateMetadata = route.generateMetadata;
export const generateStaticParams = route.generateStaticParams;
So generateMetadata lives with the route logic in the package, not in the app.
Next calls it per request, ahead of rendering Page.
The shared-fetch pattern
generateMetadata and Page both need the same content, and a naive
implementation fetches it twice per request. Every route wraps its loader in
React's cache() so the two share one Contentful request per render pass:
const loadArticle = cache(async (slug: string) => {
const result = await client.query(GetArticlePageBySlugDocument, { slug, preview: false }, …);
const item = result.articlePageCollection?.items[0];
return item ? articlePageFromFragment(item) : null;
});
generateMetadata calls loadArticle(slug); Page calls it again with the
same argument and gets the cached result. (The un-cached original fetched
twice — this is the reason the pattern exists.)
One shared contract: pageMetadata
No route builds its own Metadata object. Every
generateMetadata is ONE call into the shared module
packages/content-app/src/lib/metadata.ts (exported as
@v4/content-app/metadata, so the video routes use it too), with a typed
per-page input:
async generateMetadata({ params }) {
const { article } = await params;
const view = await loadArticle(article);
if (!view) return {};
return pageMetadata({ page: "article", view });
}
PageMetadataInput has one member per page type — article, contentPage,
home (with a path for a competition's home, /eurocup), newsListing,
category, tag, and listing (a generated listing with no entry of its own,
such as a competition's news at /news/eurocup) — plus other, which takes the
generic MetadataFields directly (the video routes build theirs in
packages/video-app/src/lib/metadata.ts). The basketball data-area routes
(packages/basketball-app/src/{teams,players,coaches,games,standings,stats}/index.tsx)
call pageMetadata too. Keeping the route change to one line
is deliberate: the route files are template-owned, so a one-line call merges
cleanly at the next /sync-upstream.
The site-wide pieces are configured once by the app, not passed through
every factory: apps/web/src/lib/metadata.ts calls configureMetadata with the
club's name, seo.baseUrl, the default locale, seo.allowRobots and the
content client (the [locale] root layout imports it for the side effect,
import "../../lib/metadata" — the lib/images.ts precedent).
What the module emits for every page:
- title — the first non-empty entry of the page's chain (below). The layout's
template still adds "|
", so a title never repeats the club name. - description — the first non-empty entry of the chain, or
nullwhen there is none: no description is published, never an empty string, and the layout's default is suppressed. - canonical (
alternates.canonical) — always absolute againstseo.baseUrl. The entry's Canonical Link (a Standard Link) wins when set, resolved by the house link resolver (canonicalHrefin@v4/model— an internal page beats an external address); otherwise the page's own address, put throughlocalePath(@v4/model/core) so it carries the locale segment (/en/news/…). - robots — only on a page with Exclude from search engines? set (Article
and Content Page):
{ index: false, follow: true }. Where the environment forbids crawling (allowRobots: false) it staysfollow: false, so a page never loosens the site-wide switch. - Open Graph and the X card — see Open Graph & sharing.
The fallback chains
Contentful's own field help is the specification: it is what editors were promised. First non-empty wins; every image chain ends at Site Settings' Default Share Image.
| Page | Title | Description | Share image |
|---|---|---|---|
| Article (news, video, gallery) | Meta Title → Article Title | Meta Description → Summary | Meta Image → the article's Image → default |
| Content Page | Meta Title → Header Block title | Meta Description → Header Block summary | Meta Image → Header Block image → default |
| Home | Meta Title → the club's name | Meta Description | Meta Image → default |
News Listing (/news, secondary) |
Meta Title → Listing Title | Meta Description | Meta Image → default |
| Category | Meta Title → the category name | Meta Description → Category Summary | Meta Image → default |
| Tag | the tag's name | the dictionary line ("Articles tagged …") | default |
| Video detail / landing / lists | the video, landing or list title | its subtitle / summary | the platform image → default |
The chains live in metadataFields() — one case per page type — and the
transforms expose the raw fields (meta.image, meta.canonical,
header.mediaRef, header.imageRef) rather than chaining them, so the module is
the one place a chain is decided.
Home with no Meta Title publishes the club's name (identity.name, the
configured site name) as the WHOLE title — title: { absolute }, so the layout's
"| content/0006).
Paged and filtered addresses
A listing's paged and filtered addresses use the same title, description and
image as the listing. Their canonical is their own address:
/news/season/2026-27/format/video/page/2 is canonical to itself, not to /news. Page 2
is different content, not a copy of page 1, and page 1 of a filtered view is the
filtered view's own address. The query-paged video lists do the same with
?page=N; the collection's query-string filters are views of one collection and
canonicalise to it.
The entry's Canonical Link names the LISTING, so it is published only on the
listing's own address. Since the season is always in the address (ruling of
9 Oct 2026), a listing's own address is page 1 of the current season with
nothing else chosen (/news/season/<current>,
/news/listing/<slug>/season/<current>,
/news/category/<slug>/season/<current> — where each bare address
redirects), and the bare address itself; every other season, format, team and
page names itself. The category route and both news listing routes
hand their metadata the current season (currentSeason on the
listing address); a failed season read never fails the metadata — the current
season is then unknown, and a season address names itself rather than being
guessed to be the listing's own.
The empty-metadata fallback
if (!view) return {} is the metadata half of the not-found path (the Page
calls notFound() for the same null). An empty object inherits the layout
defaults, so a missing page still gets a sensible title before Next renders the 404. See Errors & not-found.
Structured data (JSON-LD)
packages/content-app/src/lib/structured-data.tsx builds the schema.org data
(ported from publishing-hub-formula-e) and <JsonLd> renders it server-side as a
<script type="application/ld+json">, with < escaped as \u003c so CMS copy
can never close the script element.
| Where | Type |
|---|---|
| Every page (root layout) | WebSite + the organisation, with stable @ids (/#website, /#organization) — on ELB a SportsOrganization, Basketball |
| Every page (its route) | the page itself — a WebPage (or CollectionPage) with its BreadcrumbList |
| Article (every format) | NewsArticle — headline, dates, author, publisher (the organisation inline), share image, section, keywords |
| Video-format article | the NewsArticle carries a VideoObject |
| Video detail page | VideoObject |
| News, category and tag listings | CollectionPage whose mainEntity is the ItemList of the articles on THIS address — it is the page itself, with its trail |
| Video category, collection, playlist | CollectionPage |
| Team page | SportsTeam, memberOf its competition (a SportsOrganization, parentOrganization the site's league) |
| Player page | Person — memberOf the club (SportsTeam), nationality a Country |
| Games (a round's schedule) | CollectionPage whose mainEntity is an ItemList of SportsEvents |
| Stats (provisional, D6) | Overview: ItemLists of the leaders (Person, SportsTeam); Teams tab: CollectionPage of the ranked SportsTeams |
| Standings, team and player lists | the page and its trail only (the site-wide baseline) |
A VideoObject missing a required property (name, thumbnail, upload date) is
left out rather than published incomplete. A SportsEvent follows the same
rule: Google's event result needs a start and a venue WITH its address, and a
schedule row names its venue only. The address is read off the home club's
venues (getTeam, cached per club), so a game at its home venue is an event
and a game elsewhere (a Final Four, a tournament) is left out of the list.
sportsEventJsonLd (packages/basketball-app/src/lib/structured-data.ts) takes
a neutral shape, so the Game Center can map its own view into it when it exists.
The page itself and its trail
Every route renders
pageJsonLd(input, { parents }) beside its other data, from the SAME input its
generateMetadata passes pageMetadata (a route with { page: "other" }
builds its fields once, in a fieldsOf helper, for both), so the node is named
by the metadata's title chain and its @id is the canonical address — the
address an article's mainEntityOfPage already names. The trail starts at Home
(homeCrumb), then the competition's home where that is not the site's root
(sectionCrumbs / basketball's areaParents), then the route's own steps
(News at its season, a category, Video, the area's list); the page is the last
step, named by its title unless the route names it (crumb). The home page has
no trail: one step is not one. A listing passes its parents to
listingPageJsonLd instead, and its CollectionPage carries the trail. Not
found and error pages carry none, and neither do the temporary area pages,
which are not indexed.
Provisional: the Stats schema waits on ELB's answer to "Stats: overview, players and teams" (D6); its Players tab lists nobody, because its table loads in the browser.
Only the page on screen carries structured data
An in-site
navigation keeps the page the visitor left mounted, hidden in Cache Components'
<Activity>, so its script stays in the document: after a tap from /news to a
category the live page held both listings' CollectionPage. <JsonLd> renders
through the client JsonLdScript, whose ref switches the script's type to
application/x-hidden-page while its page is hidden and back to
application/ld+json when it is shown (Next's switch for a page-level <style>).
A validator given the live page's markup, a browser extension or a tag manager
therefore reads the page on screen only. The server's markup, which crawlers
fetch, is unchanged.
Testing structured data before production
Google's Rich Results Test obeys robots.txt,
and an environment without NEXT_PUBLIC_ROBOTS_ALLOW=true disallows every
crawler, so on dev and staging paste the page source into its Code tab instead of
the address. It reports only the types Google shows as rich results, and a list
of articles is not one of them; the Schema Markup Validator reads every
schema.org type, CollectionPage and its ItemList included.
No isAccessibleForFree. The site does not gate a Members Only article —
its body is visible to everyone — so the flag is left out entirely: false
without paywall markup reads to Google as cloaking. When real gating is built,
gated articles publish isAccessibleForFree: false with a hasPart
WebPageElement naming the gated body by cssSelector.
Search engines and the sitemap
An Article or Content Page with Exclude from search engines? set is still
reachable — it publishes noindex and the sitemap queries leave it out
(where: { excludeFromSearchEngines_not: true } in
apps/web/src/graphql/get-sitemap-entries.graphql). _not: true also keeps
every entry where the field is unset: checked on develop, the totals with and
without the filter were identical while no entry had it set. The site-wide
allowRobots switch on the layout is unchanged.
Adding metadata to a new route
Wrap the loader in cache(), call it from generateMetadata, return {} when
the content is missing, and return pageMetadata(...) — add a member to
PageMetadataInput (with its chain in metadataFields) for a new content type,
or pass { page: "other", fields }. Extend the fragment and transform if the
field you need is not selected yet. Never hand-write <meta> tags or append
image query strings: the share image is cropped through the image provider.
Open Graph & social sharing
What a page looks like when someone shares it, as the shared metadata contract
(@v4/content-app/metadata) publishes it on every page.
The share image
- The chain — the page's own image first (see the table in
Page metadata), ending at Site Settings'
Default Share Image, read once per render pass by a small cached query
tagged
content:siteSettings. Every page therefore has a share image once the default is set, including the tag and category listings. - The crop — an Image entry is cropped to 1200×630 (the large-image card
size every platform accepts) THROUGH the configured image provider:
resolveImageView(ref, SHARE_IMAGE_REQUEST), where the request is{ cropWidth: 1200, height: 630, format: "jpg" }and the editor's focal point travels in theImageRef. Never append Contentful query strings — an image served through ScorePlay (this site's default provider) gets ScorePlay's crop. JPEG because several platforms still refuse WebP. - Published with
width/height1200/630 and the image's alt text — the asset's Description, never the Image entry's Contentful Title (the admin name). With no provider able to resolve it (tests), the native asset is published at its own size. - Video pages publish the platform image at the platform's own 1200-wide crop
(
mediaImageUrl) — a Contentful crop would fail on a platform address. - An Image entry with no file attached falls through to the next image in the chain; it never publishes an empty address.
Plugging in another image provider (ScorePlay)
The share image goes through the SAME image-ref builder as every card, hero and
content block on the site: imageRefFrom in
packages/model/src/core/transforms/image-source.ts (shareImageRef is a thin
name over it). It puts the Contentful asset in ImageRef.native, the focal
point in focalPointPx, and every OTHER object-valued field of the Image
selection in ImageRef.raw, uninterpreted. The app's resolver runs the club's
enabled adapters first and Contentful last, so a provider descriptor wins
wherever one is set — and an image with ONLY a provider descriptor still
resolves, rather than falling through to the Default Share Image.
So a provider needs no edit to the metadata module or the transforms. This is how euro-league is wired for ScorePlay — the same three pieces any other provider would need, each already in place:
- The field — a migration adds
scoreplayImage(JSON) to theImagecontent type (migrations/club/0001-add-scoreplay-image-field.cjs). - The selection — add
scoreplayImagebesidefocalPointinShareImageFields(packages/model/src/content/fragments/share-image.graphql) and in the card/hero image selections, thenpnpm --filter @v4/model generate(and the route packages'generate). - The adapter — enable it in
club.config.ts(images: { defaultProvider: "scoreplay", scoreplay: {} }) and prepend@v4/image-scoreplay's adapter ahead of Contentful inapps/web/src/lib/images.ts. The adapter readsraw.scoreplayImageand answers the share card's{ cropWidth: 1200, height: 630, format: "jpg" }request with its own focal-aware crop.
Another provider would follow the same three steps with its own field and adapter.
Open Graph
Every page publishes og:title, og:description (only when there is one),
og:image, og:url (the canonical), og:site_name (the club's name) and
og:locale (from the default locale). og:type is website, or article on an
article, which also publishes article:published_time (Publish Date),
article:modified_time (when the entry was last published), article:author,
article:section (its Category) and article:tag (its public tags). The page's
author also appears as <meta name="author">.
The X (Twitter) card
Every page publishes a summary_large_image card with the same title,
description and image, so a share on X shows the large image rather than a
thumbnail.
The static baseline on the layout
The root layout sets metadataBase, the title template/default, a default
description (suppressed on any page with metadata of its own) and robots
(see Page metadata). It also renders the
site's own structured data (WebSite + Organization) and imports
lib/metadata.ts, which configures the shared contract. It sets no Open Graph
of its own — one static card for every page is exactly what the per-page
contract replaced.
Discoverability is a separate concern
Open Graph controls how a URL looks when someone shares it. Getting those
URLs found and crawled — the sitemap index, its content-tagged children, and
robots.txt — is handled separately. See the
Sitemaps guide; a page set to Exclude from search
engines? is left out of it.
Errors & not-found
Missing, hidden or out-of-range content resolves to a 404 through Next's
notFound(). This page documents how each route decides that, the three error
files apps/web renders, and the base-page-error component they are built on.
notFound() is the one signal
Every route imports notFound from next/navigation and calls it the moment
the content it needs isn't there. It throws a control-flow signal Next catches
to serve a 404 — the code after it never runs. The trigger differs per surface,
and each is a deliberate ruling rather than an incidental null-check:
| Route | notFound() when… |
|---|---|
News path (news-path) |
the segments are no listing, generated listing or article |
News article (news-article, via news-path) |
the slug resolves to no article (if (!view)) |
Home (home) |
the HomePage singleton is absent (if (!view)) |
Content page (content-page) |
the page is missing or hidden (view.hidden) |
News listing (news-listing) |
no listing entry, or page > totalPages |
News category (news-category) |
the category doesn't exist, or page > totalPages |
News tag (news-tag) |
total === 0, or page > totalPages |
Secondary listing (news-listing-by-slug) |
the slug resolves to no listing entry, or page > totalPages |
Video detail (video-detail) |
the id/slug resolves to no video (if (!detail)) |
| Video collection / playlist | the collection/playlist is missing, or page > totalPages |
Basketball data areas (teams, players, coaches, games, standings, stats) |
the address resolves to no page (if (page === undefined)) |
The patterns worth calling out:
- Out-of-range pages are real 404s, not empty grids. A listing computes
totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))and callsnotFound()whenpage > totalPages, so/news/page/999is a genuine 404. (This is the pagination guide's bounds rule seen from the SEO side — see Path-based pagination.) - Hidden is 404, not 403.
content-pagetreats an editor-hidden page (view.hidden) as not-found, so unpublished-but-referenced pages don't leak. - The address grammar is strict. A listing's page and filter segments go
through
listingAddressOrExit(packages/content-app/src/lib/listing-route.ts): a non-canonical address (pairs out of order,page/1, an old/filter/address) permanently redirects to the canonical one, and junk — including a filter key the listing does not take — is not found. - An engine outage is not a 404 or a 500. In the basketball data areas a
failed Competition Engine read (down, past
ENGINE_TIMEOUT_MS, or an error status) renders the area's own page in its designed "data unavailable" state (packages/basketball-app/src/lib/engine-failure.tsx,guardEngine). Only the engine's typed error is caught:notFound()and redirects pass through, and any other exception is a bug that reaches the error page. - The routes switch. With
BASKETBALL_ROUTES=falseevery data-area page answers the site's 404 (the middleware,apps/web/src/lib/basketball-routes.ts).
The metadata half of not-found
notFound() lives in Page. The matching generateMetadata returns {} for
the same missing content (if (!view) return {}), so the framework still has a
sensible title — the layout's title.default, the club name — while it renders
the 404. The two halves always agree: null content ⇒ Page throws
notFound(), generateMetadata returns empty. See
Page metadata for the empty-object
fallback.
The three error files
All three render apps/web/src/components/error-page-view.tsx (ErrorPageView),
which composes ghost-core's BasePageError (below).
apps/web/src/app/[locale]/not-found.tsx— the 404. Every route'snotFound()and every address no route matches. It renders inside the root layout, so the site header and footer sit around it, as the ELBError - 404frame asks. Site Settings supplies the title, message, background picture and useful links; the dictionary supplies the button labels and stands in for any copy left empty. Its two buttons go home (HOME_PATH) and to the news listing on the current season. ItsgenerateMetadatareturns the title androbots: { index: false }, and it rendersPageAnalyticswithpageType: "not_found"at the address the reader asked for.apps/web/src/app/[locale]/error.tsx— the 500. A client component for any failure while rendering a page. The ELBError - 500frame stands on its own: no header or footer, no buttons, no links, because a server error cannot rely on the chrome (or Contentful) rendering. The copy is dictionary-only, resolved by the root layout and read throughuseStandaloneErrorPage, withSERVER_ERROR_FALLBACK(components/error-page-copy.ts) as the last resort; the chrome is hidden while it is mounted.apps/web/src/app/global-error.tsx— the root layout's own failure (the chrome or the dictionary could not be read). It replaces the root layout, so it imports the theme itself (theme/fonts.css,theme/tokens.css) and rendersSERVER_ERROR_FALLBACK's copy, bundled with the app.
A real server error cannot be produced from a browser, so the 500 has a QA
address: /<locale>/qa-922b04feaf/error-500?trigger=true throws while rendering
(apps/web/src/lib/qa-error-page.ts). It is linked from nowhere,
absent from the sitemap and robots.txt, sent X-Robots-Tag: noindex, nofollow,
and without ?trigger=true it is the ordinary 404.
The branded template: base-page-error
packages/ghost-core/src/components/base-page-error/ is the Ghost 2.0
BasePage-Error organism — the page-level template behind both
BasePage-Error-404 and BasePage-Error-500. It is purely presentational:
data props and callbacks only, no routing and no status detection. It does
not call notFound(), read the URL, or know which error it is showing — the
consumer decides that and passes the right props.
Its shape, from the component:
title,message— the headline and body copy.errorCode+errorCodeLabel(default"ERROR") — the big "404 / ERROR" block.crest,backgroundImage— consumer-owned artwork slots (decorative,aria-hidden), behind a gradient scrim.backButton— an optionalButtonBack. Its presence is the 404-vs-500 switch: a 404 passesbackButton+usefulLinks; a 500 passes neither, which also derives the taller root top padding the Figma 500 binds (withBackButtonis computed frombackButton !== undefined, never a prop).usefulLinks— an optional titled group ofLinkLists (each a uniquely named<nav>landmark, to satisfy axelandmark-unique).
The site header and footer are the consumer's — the component renders neither (the Figma notes ask for chrome around the 404 and nothing around the 500).