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.

2026-11-07

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.titleTemplate in club.config.ts). When a route returns a plain string title, Next substitutes it into the %s slot, so an article titled "Match report" becomes Match report | EuroLeague Basketball. defineClubConfig rejects a titleTemplate with no %s slot.
  • 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 lets robots and canonical/OG URLs be absolute. It is read from NEXT_PUBLIC_SITE_BASE_URL (env.public, the name euro-league already uses), defaulting to http://localhost:3000 for local dev and CI only. defineClubConfig fails 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 on clubConfig.seo.allowRobots. The safe default is false, which emits { index: false, follow: false }. Here it reads NEXT_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 null when there is none: no description is published, never an empty string, and the layout's default is suppressed.
  • canonical (alternates.canonical) — always absolute against seo.baseUrl. The entry's Canonical Link (a Standard Link) wins when set, resolved by the house link resolver (canonicalHref in @v4/model — an internal page beats an external address); otherwise the page's own address, put through localePath (@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 stays follow: 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 "| " template is not applied twice. It is never the entry's Contentful Title: the admin name never reaches a front end (ruling, 7 Oct 2026; the field's help text says so from 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 the ImageRef. 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/height 1200/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:

  1. The field — a migration adds scoreplayImage (JSON) to the Image content type (migrations/club/0001-add-scoreplay-image-field.cjs).
  2. The selection — add scoreplayImage beside focalPoint in ShareImageFields (packages/model/src/content/fragments/share-image.graphql) and in the card/hero image selections, then pnpm --filter @v4/model generate (and the route packages' generate).
  3. The adapter — enable it in club.config.ts (images: { defaultProvider: "scoreplay", scoreplay: {} }) and prepend @v4/image-scoreplay's adapter ahead of Contentful in apps/web/src/lib/images.ts. The adapter reads raw.scoreplayImage and 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 calls notFound() when page > totalPages, so /news/page/999 is 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-page treats 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=false every 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's notFound() and every address no route matches. It renders inside the root layout, so the site header and footer sit around it, as the ELB Error - 404 frame 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. Its generateMetadata returns the title and robots: { index: false }, and it renders PageAnalytics with pageType: "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 ELB Error - 500 frame 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 through useStandaloneErrorPage, with SERVER_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 renders SERVER_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 optional ButtonBack. Its presence is the 404-vs-500 switch: a 404 passes backButton + usefulLinks; a 500 passes neither, which also derives the taller root top padding the Figma 500 binds (withBackButton is computed from backButton !== undefined, never a prop).
  • usefulLinks — an optional titled group of LinkLists (each a uniquely named <nav> landmark, to satisfy axe landmark-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).