Build a Headless Site with dotCMS
Add Structured Data
In this chapter you'll add JSON-LD structured data to your pages. Search engines use this to understand your content and display rich results — article authorship, breadcrumbs, site name — in search listings.
What JSON-LD is#
JSON-LD is a <script type="application/ld+json"> tag in your page <head> that describes the page's content in a structured format search engines understand. You're not changing how the page looks — you're adding a machine-readable layer on top.
Three schema.org types for this site:
| Page | Schema type | What it signals |
|---|---|---|
| Catch-all pages | WebPage | Generic page, name, URL |
| Blog listing | CollectionPage | A page that lists other pages |
| Blog detail | BlogPosting | Article with author, dates, image |
Create the JsonLd component#
You need a component that injects the JSON-LD script into the page. Create src/components/JsonLd.tsx:
interface JsonLdProps { data: Record<string, unknown>; } export function JsonLd({ data }: JsonLdProps) { if (!data || Object.keys(data).length === 0) return null; const safeJson = JSON.stringify(data).replace(/</g, "\\u003c"); return ( <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJson }} /> ); }
The .replace(/</g, "\\u003c") escapes < characters to prevent XSS while keeping the JSON valid — a Next.js best practice for inline scripts.
Add the builder functions to seo.ts#
Open src/utils/seo.ts and add these functions after buildPageMetadata. Start with getAbsoluteImageUrl — the structured data builders depend on it:
export function getAbsoluteImageUrl( idPathOrUrl: string | null | undefined, ): string | null { if (!idPathOrUrl || typeof idPathOrUrl !== "string") return null; if (idPathOrUrl.startsWith("http")) return idPathOrUrl; const host = (process.env.NEXT_PUBLIC_DOTCMS_HOST || "").replace(/\/$/, ""); if (!host) return null; const path = idPathOrUrl.startsWith("/dA/") ? idPathOrUrl : `/dA/${idPathOrUrl}`; return `${host}${path}`; } interface WebPageParams { title?: string; description?: string; path?: string; } interface ArticleParams { title?: string; description?: string; authorName?: string; datePublished?: string; dateModified?: string; imageUrl?: string; path?: string; } export function buildWebPageStructuredData({ title, description, path, }: WebPageParams): Record<string, unknown> { const url = toAbsoluteUrl(path || "/"); return { "@context": "https://schema.org", "@type": "WebPage", name: title || undefined, description: description || undefined, url: url || undefined, }; } export function buildCollectionPageStructuredData({ title, description, path, }: WebPageParams): Record<string, unknown> { const url = toAbsoluteUrl(path || "/blog"); return { "@context": "https://schema.org", "@type": "CollectionPage", name: title || "Blog", description: description || undefined, url: url || undefined, }; } export function buildArticleStructuredData({ title, description, authorName, datePublished, dateModified, imageUrl, path, }: ArticleParams): Record<string, unknown> { const url = path ? toAbsoluteUrl(path) : undefined; return { "@context": "https://schema.org", "@type": "BlogPosting", headline: title || undefined, description: description || undefined, url: url || undefined, datePublished: datePublished || undefined, dateModified: dateModified || datePublished || undefined, author: authorName ? { "@type": "Person", name: authorName } : undefined, image: imageUrl ? { "@type": "ImageObject", url: imageUrl } : undefined, }; }
toAbsoluteUrl is already defined in seo.ts from Chapter 7 — these functions use it too.
Add structured data to the catch-all page#
Open src/app/[[...slug]]/page.tsx. Import JsonLd, buildWebPageStructuredData, and add the script to the return:
import { notFound } from "next/navigation"; import type { Metadata } from "next"; import { getDotCMSPage } from "@/utils/getDotCMSPage"; import { navigationQuery } from "@/utils/queries"; import { buildPageMetadata, buildWebPageStructuredData } from "@/utils/seo"; import { JsonLd } from "@/components/JsonLd"; 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 ?? []; const page = pageContent.pageAsset?.page; const jsonLd = buildWebPageStructuredData({ title: page?.friendlyName || page?.title, description: page?.seodescription, path, }); return ( <> <JsonLd data={jsonLd} /> {layout?.header && <Header navItems={navItems} />} <Page pageContent={pageContent} /> {layout?.footer && <Footer />} </> ); }
Add structured data to the blog listing page#
Open src/app/blog/page.tsx and add CollectionPage structured data:
import { notFound } from "next/navigation"; import type { Metadata } from "next"; import { buildSlots } from "@dotcms/react"; import { getDotCMSPage } from "@/utils/getDotCMSPage"; import { blogListGraphQL } from "@/utils/queries"; import { buildPageMetadata, buildCollectionPageStructuredData, } from "@/utils/seo"; import { JsonLd } from "@/components/JsonLd"; 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, blogListGraphQL); 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, blogListGraphQL); if (!pageContent) return notFound(); const layout = pageContent.pageAsset?.layout; const navItems = pageContent.content?.navigation?.children ?? []; const page = pageContent.pageAsset?.page; const jsonLd = buildCollectionPageStructuredData({ title: getBlogTitle(page), description: page?.seodescription, path: PATH, }); const slots = await buildSlots(pageContent.pageAsset.containers, { BlogList, }); return ( <> <JsonLd data={jsonLd} /> {layout?.header && <Header navItems={navItems} />} <BlogListingPage pageContent={pageContent} slots={slots} /> {layout?.footer && <Footer />} </> ); }
Add structured data to the blog detail page#
The detail page has the richest structured data — author, dates, and image. Open src/app/blog/[...slug]/page.tsx:
import { notFound } from "next/navigation"; import type { Metadata } from "next"; import { getDotCMSPage } from "@/utils/getDotCMSPage"; import { blogDetailGraphQL } from "@/utils/queries"; import { buildArticleStructuredData, buildPageMetadata, getAbsoluteImageUrl } from "@/utils/seo"; import { DetailPage } from "@/views/DetailPage"; import type { BlogURLContentMap } from "@/types/blog"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; import { JsonLd } from "@/components/JsonLd"; interface PageProps { params: Promise<{ slug: string[] }>; } function getBlogDetailMeta(blog: BlogURLContentMap | undefined) { const title = blog?.title ? `${blog.title} - Blog` : "Blog"; const description = blog?.description; const imageUrl = blog?.image?.idPath ? getAbsoluteImageUrl(blog.image.idPath) ?? undefined : undefined; const publishDate = blog?.publishDate ? new Date(blog.publishDate).toISOString() : undefined; const modDateMs = Number(blog?.modDate); const modDate = blog?.modDate && !isNaN(modDateMs) ? new Date(modDateMs).toISOString() : undefined; const author = blog?.author?.[0]; const authorName = author ? [author.firstName, author.lastName].filter(Boolean).join(" ") : undefined; return { title, description, imageUrl, publishDate, modDate, authorName }; } 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 ?? []; const { title, description, imageUrl, publishDate, modDate, authorName } = getBlogDetailMeta(pageContent.pageAsset?.urlContentMap as BlogURLContentMap | undefined); const jsonLd = buildArticleStructuredData({ title, description, authorName, datePublished: publishDate, dateModified: modDate, imageUrl, path, }); return ( <> <JsonLd data={jsonLd} /> {layout?.header && ( <Header navItems={navItems} /> )} <DetailPage pageContent={pageContent} /> {layout?.footer && <Footer />} </> ); }
getBlogDetailMeta extracts and normalises the blog post fields once, then both generateMetadata and the page body use the result. getAbsoluteImageUrl converts the dotCMS idPath into a full URL — needed because Open Graph image URLs must be absolute.
Try it#
Run npm run dev and open a blog post. In your browser dev tools, search the page source for application/ld+json. You should see a BlogPosting object with the article title, author, dates, and image URL.
You can also paste your URL into Google's Rich Results Test to verify the structured data is valid.
Checkpoint#
-
src/components/JsonLd.tsxcreated -
buildWebPageStructuredData,buildCollectionPageStructuredData,buildArticleStructuredData,getAbsoluteImageUrladded toseo.ts -
<JsonLd>rendered in catch-all, blog listing, and blog detail pages -
application/ld+jsonscript appears in page source for each page type
Next up
Chapter 9: Handle Vanity Redirects