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:

PageSchema typeWhat it signals
Catch-all pagesWebPageGeneric page, name, URL
Blog listingCollectionPageA page that lists other pages
Blog detailBlogPostingArticle 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.tsx created
  • buildWebPageStructuredData, buildCollectionPageStructuredData, buildArticleStructuredData, getAbsoluteImageUrl added to seo.ts
  • <JsonLd> rendered in catch-all, blog listing, and blog detail pages
  • application/ld+json script appears in page source for each page type

Next up

Chapter 9: Handle Vanity Redirects

Continue →