Build a Headless Site with dotCMS
Add Page Metadata
In this chapter you'll add page metadata to the site — titles, descriptions, and Open Graph tags. dotCMS stores this data on each page; you'll read it from the page response and pass it to Next.js generateMetadata.
Where the data comes from#
Every page in dotCMS has built-in SEO fields:
| dotCMS field | What it is |
|---|---|
page.friendlyName | The human-readable page title |
page.title | The internal page title (fallback) |
page.seodescription | The meta description |
These come back in pageAsset.page on every getDotCMSPage call — no extra GraphQL query needed.
Create the SEO utility#
Rather than constructing the Metadata object in each page file, you'll centralise the logic in one utility.
Create src/utils/seo.ts:
import type { Metadata } from "next"; function getBaseUrl(): string { return process.env.NEXT_PUBLIC_SITE_URL || ""; } function toAbsoluteUrl(path: string): string { const base = getBaseUrl().replace(/\/$/, ""); const pathStr = path.startsWith("/") ? path : `/${path}`; return pathStr === "/" ? base : `${base}${pathStr}`; } export function buildPageMetadata({ title, description, path, imageUrl, type = "website", }: { title?: string; description?: string; path?: string; imageUrl?: string; type?: "website" | "article"; }): Metadata { const url = toAbsoluteUrl(path || "/") || undefined; return { title: title || "Page", description, alternates: { canonical: url }, openGraph: { title, description, url, type, ...(imageUrl && { images: [{ url: imageUrl }] }), }, twitter: { card: "summary_large_image", title, description, }, }; }
toAbsoluteUrl builds a canonical URL from NEXT_PUBLIC_SITE_URL + the page path. You'll need to add that env var to your .env.local:
NEXT_PUBLIC_SITE_URL=http://localhost:3000
And to Vercel when you deploy (your live domain, e.g. https://my-banking-site.vercel.app).
Add metadata to the catch-all page#
Next.js supports two ways to set metadata. You can export a static metadata constant — simple, but the values have to be known at build time. Because your titles and descriptions come from dotCMS at request time, you need generateMetadata instead: an async function that fetches the page data and returns the Metadata object dynamically.
Open src/app/[[...slug]]/page.tsx and add generateMetadata:
import { notFound } from "next/navigation"; import type { Metadata } from "next"; import { getDotCMSPage } from "@/utils/getDotCMSPage"; import { navigationQuery } from "@/utils/queries"; import { buildPageMetadata } from "@/utils/seo"; import { Page } from "@/views/Page"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; interface PageProps { params: Promise<{ slug?: string[] }>; } function resolvePath(slug?: string[]): string { return `/${(slug ?? []).join("/")}`; } export async function generateMetadata({ params }: PageProps): Promise<Metadata> { const { slug } = await params; const path = resolvePath(slug); try { const pageData = await getDotCMSPage(path); if (!pageData) return { title: "Not found" }; const page = pageData.pageAsset?.page; return buildPageMetadata({ title: page?.friendlyName || page?.title, description: page?.seodescription, path, }); } catch { return { title: "Not found" }; } } export default async function CatchAllPage({ params }: PageProps) { const { slug } = await params; const path = resolvePath(slug); const pageContent = await getDotCMSPage(path, { content: { navigation: navigationQuery }, }); if (!pageContent) return notFound(); const layout = pageContent.pageAsset?.layout; const navItems = pageContent.content?.navigation?.children ?? []; return ( <> {layout?.header && <Header navItems={navItems} />} <Page pageContent={pageContent} /> {layout?.footer && <Footer />} </> ); }
generateMetadata calls getDotCMSPage without the navigation query — it only needs the page fields for the title and description. Thanks to React cache(), if CatchAllPage calls getDotCMSPage with the same path, the second call is deduplicated and costs nothing.
Add metadata to the blog listing page#
Open src/app/blog/page.tsx and add generateMetadata:
import { notFound } from "next/navigation"; import type { Metadata } from "next"; import { buildSlots } from "@dotcms/react"; import { getDotCMSPage } from "@/utils/getDotCMSPage"; import { navigationQuery } from "@/utils/queries"; import { buildPageMetadata } from "@/utils/seo"; import { BlogListingPage } from "@/views/BlogListingPage"; import BlogList from "@/components/content-types/BlogList"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; const PATH = "/blog"; const FALLBACK_DESCRIPTION = "Read our latest articles."; function getBlogTitle(page?: { friendlyName?: string; title?: string }): string { const pageTitle = page?.friendlyName || page?.title; return pageTitle ? `${pageTitle} - Blog` : "Blog"; } export async function generateMetadata(): Promise<Metadata> { try { const pageData = await getDotCMSPage(PATH, { content: { navigation: navigationQuery } }); if (!pageData) return { title: "Not found" }; const page = pageData.pageAsset?.page; return buildPageMetadata({ title: getBlogTitle(page), description: page?.seodescription || FALLBACK_DESCRIPTION, path: PATH, }); } catch { return { title: "Not found" }; } } export default async function BlogPage() { const pageContent = await getDotCMSPage(PATH, { content: { navigation: navigationQuery } }); if (!pageContent) return notFound(); const layout = pageContent.pageAsset?.layout; const navItems = pageContent.content?.navigation?.children ?? []; const slots = await buildSlots(pageContent.pageAsset.containers, { BlogList, }); return ( <> {layout?.header && <Header navItems={navItems} />} <BlogListingPage pageContent={pageContent} slots={slots} /> {layout?.footer && <Footer />} </> ); }
getBlogTitle formats the title as "{Page Title} - Blog" if the page has a title, or falls back to just "Blog".
Add metadata to the blog detail page#
Open src/app/blog/[...slug]/page.tsx and add generateMetadata:
import { notFound } from "next/navigation"; import type { Metadata } from "next"; import { getDotCMSPage } from "@/utils/getDotCMSPage"; import { blogDetailGraphQL } from "@/utils/queries"; import { buildPageMetadata } from "@/utils/seo"; import { DetailPage } from "@/views/DetailPage"; import type { BlogURLContentMap } from "@/types/blog"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; interface PageProps { params: Promise<{ slug: string[] }>; } export async function generateMetadata({ params }: PageProps): Promise<Metadata> { const { slug } = await params; const path = `/blog/${slug.join("/")}`; try { const pageData = await getDotCMSPage(path, blogDetailGraphQL); if (!pageData) return { title: "Not found" }; const urlContentMap = pageData.pageAsset?.urlContentMap as BlogURLContentMap | undefined; const title = urlContentMap?.title ? `${urlContentMap.title} - Blog` : "Blog"; return buildPageMetadata({ title, description: urlContentMap?.description, path, type: "article", }); } catch { return { title: "Not found" }; } } export default async function BlogDetailPage({ params }: PageProps) { const { slug } = await params; const path = `/blog/${slug.join("/")}`; const pageContent = await getDotCMSPage(path, blogDetailGraphQL); if (!pageContent) return notFound(); const layout = pageContent.pageAsset?.layout; const navItems = pageContent.content?.navigation?.children ?? []; return ( <> {layout?.header && ( <Header navItems={navItems} /> )} <DetailPage pageContent={pageContent} /> {layout?.footer && <Footer />} </> ); }
The detail page uses type: "article" — this sets og:type to article instead of website, which gives better Open Graph previews when the post is shared on social.
Try it#
Run npm run dev and inspect the <head> of each page in your browser's dev tools. You should see:
<title>populated from dotCMS<meta name="description">fromseodescription<meta property="og:title">and<meta property="og:description">
Go to dotCMS admin, edit the seodescription field on any page, refresh — the meta tag updates.
Checkpoint#
-
src/utils/seo.tscreated withbuildPageMetadata -
NEXT_PUBLIC_SITE_URLadded to.env.local -
generateMetadataadded to the catch-all, blog listing, and blog detail pages -
<title>and<meta name="description">appear correctly in the browser
Next up
Chapter 8: Add Structured Data