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