Developer Demo
SEO & Metadata
How a headless CMS shifts full SEO responsibility to the app layer - and how to implement metadata, sitemaps, structured data, and optimized images in Next.js.
SEO is the app's responsibility in headless#
A traditional CMS generates <title>, meta description, and sitemap.xml automatically from its page schema. In a headless setup the CMS only provides content data - the app must read that data and write the HTML. Next.js App Router provides three dedicated export points for this: generateMetadata(), app/sitemap.ts, and app/robots.ts.
// In a traditional CMS (WordPress, Drupal) the platform generates <title>,
// meta description, and sitemap.xml automatically.
// In a headless setup that responsibility moves entirely to the app layer.
//
// Next.js App Router provides three dedicated export points:
//
// generateMetadata() → <head> tags for a specific page
// generateSitemap() → /sitemap.xml (app/sitemap.ts)
// generateRobots() → /robots.txt (app/robots.ts)
//
// All three are server functions that can fetch from Graph.
// None are provided automatically - you must implement them.generateMetadata()
src/app/[[...slug]]/page.tsx
Per-page - reads CMS content fields (title, description, OG image) and returns a Metadata object. Runs before the page renders.
app/sitemap.ts
src/app/sitemap.ts
Site-wide - queries Graph for all published page URLs and returns them in the Next.js MetadataRoute.Sitemap format. Served at /sitemap.xml.
app/robots.ts
src/app/robots.ts
Static - defines crawl rules and points to the sitemap. Served at /robots.txt. Can be a static file or a dynamic function.
generateMetadata() from CMS fields#
generateMetadata() is a server function that runs before the page component. It receives the same params and searchParams as the page and can fetch from Graph. Keep the query small - only fetch the fields you need for metadata, not the full page composition. SDK docs ↗
// src/app/[[...slug]]/page.tsx
//
// generateMetadata() runs before the page component renders.
// Return a Metadata object - Next.js writes the <head> tags.
import type { Metadata } from "next";
import { graphqlFetch } from "@/lib/optimizely/client";
const GET_PAGE_SEO_QUERY = /* GraphQL */ `
query GetPageSeo($url: String!) {
_Content(
where: { _metadata: { url: { default: { eq: $url } } } }
limit: 1
) {
items {
_metadata { displayName url { default } }
... on LandingPage {
metaTitle
metaDescription
ogImage { _metadata { url { default } } }
}
... on ArticlePage {
metaTitle
metaDescription
heroImage { _metadata { url { default } } }
}
}
}
}
`;
export async function generateMetadata(
{ params }: { params: { slug?: string[] } }
): Promise<Metadata> {
const url = `/${params.slug?.join("/") ?? ""}`;
const res = await graphqlFetch(GET_PAGE_SEO_QUERY, { url }, { next: { revalidate: 60 } });
const page = res.data?._Content?.items?.[0];
const title = page?.metaTitle ?? page?._metadata?.displayName ?? "Mosey Bank";
const description = page?.metaDescription ?? undefined;
const imageUrl = page?.ogImage?._metadata?.url?.default
?? page?.heroImage?._metadata?.url?.default;
return {
title,
description,
openGraph: {
title,
description,
url: page?._metadata?.url?.default,
images: imageUrl ? [{ url: imageUrl }] : [],
},
twitter: { card: "summary_large_image", title, description },
};
}Opal can audit and write these fields automatically
The SEO Metadata Optimization Agent evaluates live URLs for metadata gaps and scores each field against length, keyword density, and click-through best practices. The SEO Metadata Implementation Agent then takes those recommendations and patches the CMS content item's metaTitle and metaDescription fields directly via the Management API - the same fields generateMetadata() reads above.
Sitemap from Graph#
The sitemap generator reuses GET_ALL_PAGE_PATHS_QUERY - the same query already used by generateStaticParams() in the catch-all route. Set a long revalidate (3600s) - sitemap regeneration is expensive and sitemaps don't need to be real-time.
// src/app/sitemap.ts
//
// Next.js calls this function and serves the result as /sitemap.xml.
// Use the same GetAllPagePaths query used by generateStaticParams.
import type { MetadataRoute } from "next";
import { graphqlFetch } from "@/lib/optimizely/client";
import { GET_ALL_PAGE_PATHS_QUERY } from "@/lib/graphql/queries/GetAllPagePaths";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const res = await graphqlFetch(GET_ALL_PAGE_PATHS_QUERY, {}, { next: { revalidate: 3600 } });
const pages = res.data?._Page?.items ?? [];
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://moseyfin.com";
return pages
.filter((p) => p._metadata?.url?.default)
.map((p) => ({
url: `${baseUrl}${p._metadata.url.default}`,
lastModified: p._metadata.published ? new Date(p._metadata.published) : new Date(),
changeFrequency: "weekly" as const,
priority: p._metadata.url.default === "/" ? 1 : 0.8,
}));
}
// Output at /sitemap.xml:
// <urlset>
// <url>
// <loc>https://moseyfin.com/en/about</loc>
// <lastmod>2025-06-01T00:00:00.000Z</lastmod>
// <changefreq>weekly</changefreq>
// <priority>0.8</priority>
// </url>
// ...
// </urlset>Structured data (JSON-LD)#
JSON-LD structured data helps search engines interpret page content as Articles, BreadcrumbLists, FAQs, and other typed schemas. Inject it as a <script type="application/ld+json"> tag in the page component (not in generateMetadata() - that's for standard meta tags). Use Next.js <Script> with id and dangerouslySetInnerHTML - values come from the CMS, not from user input, so XSS risk is low but you should still validate CMS content before serializing.
// Structured data (JSON-LD) helps search engines understand page content.
// Add it as a <script type="application/ld+json"> tag in generateMetadata
// or inline in the page component.
// src/app/[[...slug]]/page.tsx - Article structured data
import Script from "next/script";
export default async function CmsPage({ params }) {
const page = await fetchPage(params);
const jsonLd = page.__typename === "ArticlePage" ? {
"@context": "https://schema.org",
"@type": "Article",
headline: page._metadata.displayName,
description: page.metaDescription,
datePublished: page._metadata.published,
dateModified: page._metadata.changed,
image: page.heroImage?._metadata?.url?.default,
author: {
"@type": "Organization",
name: "Mosey Bank",
},
} : null;
return (
<>
{jsonLd && (
<Script
id="article-jsonld"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)}
<OptimizelyComponent content={page} />
</>
);
}
// Breadcrumb structured data - great for navigational pages:
const breadcrumbLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: ancestors.map((a, i) => ({
"@type": "ListItem",
position: i + 1,
name: a.displayName,
item: `https://moseyfin.com${a.url}`,
})),
};Next.js <Image> with CMS-hosted assets#
Next.js requires all remote image domains to be allowlisted in next.config before <Image> will accept them. Optimizely DAM images come from the CMS domain plus the DAM CDN. The SDK's damAssets() helper builds a responsive srcset from the DAM image URL and extracts AltText from the DAM asset metadata - both are important for Core Web Vitals and accessibility.
// Next.js <Image> requires all remote domains to be allowlisted in next.config.
// Optimizely DAM images come from the CMS domain - add it to remotePatterns.
// next.config.mjs
export default {
images: {
remotePatterns: [
{ protocol: "https", hostname: "*.cms.optimizely.com" },
{ protocol: "https", hostname: "*.opticdn.net" }, // DAM CDN
],
},
};
// In a component - use the SDK's damAssets helper for responsive images:
import { damAssets } from "@optimizely/cms-sdk";
import Image from "next/image";
export default function HeroBlock({ content }) {
const { getSrcset, getAlt, isDamImageAsset } = damAssets(content);
if (!isDamImageAsset(content.backgroundImage)) return null;
return (
<Image
src={content.backgroundImage._metadata.url.default}
srcSet={getSrcset(content.backgroundImage, [480, 800, 1200, 1600])}
alt={getAlt(content.backgroundImage, "Hero image")}
fill
sizes="100vw"
priority
/>
);
}
// In preview/edit mode, use src() from getPreviewUtils to append the preview token:
const { pa, src } = getPreviewUtils(content);
<Image src={src(content.backgroundImage)} ... />robots.ts and canonical URLs#
Block crawlers from internal routes (/preview, /api/) via app/robots.ts. Always set a canonical URL in generateMetadata() using _metadata.url.default from Graph - this is the CMS's authoritative URL for the content. For multi-locale sites, add hreflang alternates to tell search engines about language variants.
// src/app/robots.ts - served at /robots.txt by Next.js
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://moseyfin.com";
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/preview", // editorial preview route - not for bots
"/api/", // API routes
"/_next/", // internal Next.js assets
],
},
],
sitemap: `${siteUrl}/sitemap.xml`,
host: siteUrl,
};
}
// Canonical URLs - add to generateMetadata for every page:
alternates: {
canonical: `${siteUrl}${page._metadata.url.default}`,
// For multi-locale pages, add hreflang:
languages: {
en: `${siteUrl}/en${slug}`,
fr: `${siteUrl}/fr${slug}`,
},
},Key Things to Know#
- →Headless shifts all SEO work to the app layer. The CMS provides raw field data. generateMetadata(), sitemap.ts, and robots.ts are yours to implement - nothing is automatic.
- →Use a separate, small query in generateMetadata(). Fetch only the SEO fields - don't reuse the full page composition query. This keeps the metadata fetch fast and independently cacheable.
- →Sitemap revalidate should be long (3600s+). Sitemaps are read infrequently by crawlers. Regenerating on every request wastes Graph quota with no SEO benefit.
- →Inject JSON-LD in the page component, not in generateMetadata. Next.js only writes standard meta tags from the Metadata object. Use a <Script> tag for structured data.
- →Allowlist CMS image domains in next.config before using <Image>. Missing domains cause a 400 error at runtime, not at build time - easy to miss in dev but visible in production.
- →Use _metadata.url.default as the canonical URL. This is the CMS's authoritative URL for the content. Don't reconstruct it from params - they can diverge if the CMS URL is updated.
Automate the SEO cycle with Opal#
Everything on this page - metadata fields, sitemap freshness, structured data, robots rules - can be audited and updated automatically. Opal ships a set of specialized agents that read your live site via Graph, surface issues, and write fixes back to the CMS via the Management API.
GEO Auditor Agent
Checks AI crawler accessibility, Core Web Vitals, schema markup, citation readiness, and E-E-A-T signals - then outputs a prioritized action plan.
SEO Metadata Optimization Agent
Evaluates metaTitle, metaDescription, and OG fields for every URL - scores them and surfaces specific improvements ranked by impact.
SEO Metadata Implementation Agent
Takes the Optimization Agent's output and writes the improved metadata directly into the CMS content item via the Management API. No copy-paste.
Full Opal use-cases demo
GEO/SEO agents, content creation and review workflows, workflow orchestration triggers, and the developer SDK for building custom tools Opal can call.