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
MovieDetailsPageandShowDetailsPage.
0. Prerequisites & ground rules
- Branch off from the Phase 1 branch or
developonce Phase 1 is merged. baseUrlis available fromsrc/config/app.ts.metadataBaseis now set (Phase 1 Task 5) — all relative image paths in metadata resolve automatically.- The
getMetadata()helper insrc/utils/metadata.tsmust 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 —
CMSPagehas no reliablepathfield), the canonical is derived once in the catch-all route from therouteSegmentsit already receives. Public detail pages are served throughsrc/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 becauserouteSegmentsnever includes query params. Detail and Modular views therefore do not setalternates.canonicalthemselves — 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:urlidentify 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 buildsucceeds. 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_imagewith 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-reviewskill reports no BLOCKERs.