Skip to main content

Phase 2 — Metadata & Canonical URLs

Goal: every indexable page declares a stable canonical URL and has complete <title>, <meta description>, Open Graph, and Twitter tags populated from real asset/CMS data — not template defaults.

Prerequisite: Phase 1 merged and passing CI. No additional external dependencies. OG images use the asset thumbnail from the OVP provider, which is already fetched in MovieDetailsPage and ShowDetailsPage.


0. Prerequisites & ground rules

  • Branch off from the Phase 1 branch or develop once Phase 1 is merged.
  • baseUrl is available from src/config/app.ts.
  • metadataBase is now set (Phase 1 Task 5) — all relative image paths in metadata resolve automatically.
  • The getMetadata() helper in src/utils/metadata.ts must be used for all metadata construction so the base defaults are always merged correctly.

Task 1 — Canonical URLs on indexable pages

A canonical URL tells search engines which URL is the authoritative version of a page, preventing duplicate-content penalties from URL parameters, trailing slashes, or the same content accessible at multiple paths.

In the Next.js App Router, canonicals are declared via alternates: { canonical: '<url>' } in the Metadata object returned by generateMetadata.

As-built decision — canonical is centralised, not per-page. Rather than build a canonical inside every view's metadata (which would need each view to know its own URL slug — CMSPage has no reliable path field), the canonical is derived once in the catch-all route from the routeSegments it already receives. Public detail pages are served through src/app/[[...routeSegments]]/page.tsx, so a single place covers them all. The view metadata functions stay focused on title/description/keywords/OG-type.

1.1 Canonical helper (src/utils/metadata.ts)

Add a small pure helper so the derivation is reusable and unit-testable:

/**
* Builds an absolute canonical URL from the catch-all route segments.
* Empty segments are dropped so the home page ('' / ['']) resolves to the
* bare baseUrl and no trailing slash leaks into detail/listing URLs.
*/
export const getCanonicalUrl = (routeSegments: string[] = []): string => {
const path = routeSegments.filter(Boolean).join('/');
return path ? `${baseUrl}/${path}` : baseUrl;
};

1.2 Root catch-all page (src/app/[[...routeSegments]]/page.tsx)

generateMetadata already resolves the view metadata via getPageAndMetadata. Wrap it with getMetadata (so base defaults merge in) and then attach the canonical and openGraph.url derived from the segments:

export async function generateMetadata({
params,
}: {
params: Promise<{ routeSegments?: string[] }>;
}): Promise<Metadata> {
const { routeSegments = [''] } = await params;
const pageAndMetadata = await getPageAndMetadata(routeSegments, undefined);
const metadata: Metadata = getMetadata({ ...pageAndMetadata?.metadata });

// Canonical URL is derived from the route segments so it is always
// consistent regardless of query-string or trailing-slash variations.
const canonicalUrl = getCanonicalUrl(routeSegments);

return {
...metadata,
alternates: { canonical: canonicalUrl },
openGraph: { ...metadata.openGraph, url: canonicalUrl },
};
}

For pages with URL parameters (e.g. ?sort=asc), this correctly points the canonical at the parameterless URL because routeSegments never includes query params. Detail and Modular views therefore do not set alternates.canonical themselves — they inherit it here.


Task 2 — Complete Open Graph tags on detail pages

baseMetadata sets og:type: website, og:title, and a static logo og:image. The catch-all (Task 1.2) injects og:url. Detail pages override og:type and provide an asset-specific og:image; everything merges through getMetadata.

2.1 Resolving an OG image — reuse getImageSrc

The Image data model (src/dataModels/image.ts) has no width / height / aspectRatio / url fields — it exposes a type enum (backdrop_clean, backdrop, poster, …) and a templateSrc. So instead of a bespoke buildOgImages helper, reuse the OVP provider's existing getImageSrc, which resolves a templateSrc to a concrete, sized URL. Request a landscape backdrop_clean at the OG-recommended 1200×630:

const ogImageSrc = await ovpProvider.getImageSrc({
images: movieDetail.images,
type: 'backdrop_clean',
width: 1200,
height: 630,
quality: '70',
});

getImageSrc falls back to the first available image when no backdrop_clean exists and returns '' when there are none — so guard before adding the images key.

2.2 MovieDetailsPage OpenGraph (src/views/MovieDetails/MovieDetailsPage.tsx)

In getPageData, after resolving ogImageSrc:

const metadata: Metadata = {
title: interpolateTextWithObject(page.seo?.metaTitle ?? movieDetail.title, {
assetTitle: movieDetail.title,
}),
description: interpolateTextWithObject(
page.seo?.metaDescription ??
movieDetail.description ??
`Movie Detail ${id}`,
{ assetDescription: movieDetail.description ?? '' },
),
keywords: page.seo?.metaKeywords ?? getDefaultKeywords(),
openGraph: {
type: 'video.movie',
...(ogImageSrc && { images: [{ url: ogImageSrc }] }),
},
};

The error/fallback branch sets openGraph: { type: 'video.movie' } only (no image).

2.3 ShowDetailsPage OpenGraph (src/views/ShowDetails/ShowDetailsPage.tsx)

Identical pattern with type: 'video.tv_show':

openGraph: {
type: 'video.tv_show',
...(ogImageSrc && { images: [{ url: ogImageSrc }] }),
},

2.4 Protected modular pages

Modular pages keep the base og:type: website and the static logo og:image from baseMetadata; the catch-all adds their og:url when they render for an authenticated user. Home, Category, and Listing are not part of the anonymous SEO surface and must not appear in the sitemap or the public metadata QA matrix.


Task 3 — Twitter card type

The current baseMetadata sets twitter: {} which defaults to card type summary (small square thumbnail). For video content, summary_large_image is correct — it renders a full-width banner in tweets.

File: src/utils/metadata.ts

export const baseMetadata: Metadata = {
// ... existing fields ...
twitter: {
card: 'summary_large_image',
},
};

Next.js automatically maps og:title, og:description, and og:image to the twitter: equivalents when not explicitly set — no further Twitter-specific fields are needed.


Task 4 — og:description wired from description

baseMetadata currently has no og:description. Next.js does not automatically map <meta name="description"> to og:description — they must be set separately.

The getMetadata() helper merges OpenGraph shallowly. Ensure that when a page passes description, it is also propagated to openGraph.description.

File: src/utils/metadata.ts

Update getMetadata():

export const getMetadata = ({
title,
description,
openGraph,
twitter,
robots,
keywords,
...props
}: Metadata): Metadata => {
return {
...baseMetadata,
...props,
title,
description,
keywords: keywords ?? getDefaultKeywords(),
openGraph: {
...baseMetadata.openGraph,
description, // ← propagate description to OG automatically
...openGraph, // ← caller can still override
},
twitter: {
...baseMetadata.twitter,
...twitter,
},
robots:
typeof robots === 'string'
? robots
: {
...(baseMetadata.robots as Exclude<Metadata['robots'], string>),
...robots,
},
};
};

Task 5 — Default keywords update

Replace the Assemble platform keywords with RecordPlus-relevant terms. These appear as the fallback on all pages that do not supply their own metaKeywords from the CMS.

File: src/utils/metadata.ts

const defaultKeywords = [
'streaming',
process.env.NEXT_PUBLIC_APP_TITLE ?? 'RECORD_BRAND_NAME',
'filmes',
'séries',
'ao vivo',
'Record',
];

Task 6 — Audit public detail metadata

Public detail pages combine CMS metadata templates with JWX content title, description, and image data. If CMS fields are empty, the content-specific fallback must remain valid and must not collapse every detail page to the root default title.

Action: in a deployed environment with the RecordPlus CMS config loaded, inspect representative Movie, Show, Podcast, Radio, and Channel detail pages:

  • Confirm each valid detail page has a content-specific title and description.
  • Confirm canonical and og:url identify the same detail URL.
  • Confirm unavailable or invalid content emits noindex.

Document the result in the PR description.


Task 7 — Sitemap lastModified improvement

The audited implementation no longer emits lastModified: new Date(). Generating the current request or build time for every URL falsely tells crawlers that every page changed.

Keep lastModified absent until the JWX-backed catalogue feed exposes a trustworthy per-content modification timestamp. When available, validate the timestamp and emit it only for that entry; never fall back to the current time.

Google ignores sitemap priority and changeFrequency, so the implementation also omits those fields.


Verification checklist (Phase 2 done when all pass)

  • pnpm build succeeds. No TypeScript errors.
  • pnpm test — all existing tests pass; update any that assert on metadata shape.
  • View source on a Movie detail page:
    • <link rel="canonical" href="https://.../filmes/<id>"/> present and correct.
    • <meta property="og:type" content="video.movie"/> present.
    • <meta property="og:image" content="<thumbnail-url>"/> present and is an absolute URL.
    • <meta property="og:description" content="..."/> present and not empty.
    • <meta name="twitter:card" content="summary_large_image"/> present.
  • View source on a Show detail page — same checks with og:type: video.tv_show.
  • View source on representative Podcast, Radio, Channel, and other supported detail pages — title, description, canonical, Open Graph, Twitter, and robots metadata match the available content contract.
  • Anonymous Home, Category, Listing, and Search requests redirect to Login and are absent from the sitemap.
  • Facebook Sharing Debugger (developers.facebook.com/tools/debug) on a Movie detail URL — shows title, description, and thumbnail image (not just the logo).
  • Twitter Card Validator (cards-dev.twitter.com/validator) on the same URL — shows summary_large_image with the asset thumbnail.
  • No "Assemble Web" string appears in any <head> tag on any indexable page.
  • A simulated OVP image-resolution failure keeps the detail page usable and falls back to the global social image.
  • /code-review skill reports no BLOCKERs.