Build a Headless Site with dotCMS

Connect to dotCMS

In this chapter you'll build the home page. By the end, your Next.js app will fetch the home page from dotCMS and render a hero banner and a list of blog posts — all driven by content the author controls in dotCMS.

Before writing any code, you need to understand how routing works in this setup.

How routing works#


In a traditional Next.js app you create a file for each page: app/about/page.tsx, app/contact/page.tsx, and so on. That works when you control the URL structure.

With dotCMS, the CMS owns the pages. Content authors create and manage pages in dotCMS admin — your Next.js app doesn't know what pages exist or what their URLs are.

The solution is a catch-all route:

src/app/[[...slug]]/page.tsx

This single file handles every URL your dotCMS instance serves:

URLslug valueFetches
/undefinedclient.page.get("/")
/about["about"]client.page.get("/about")
/products/cards["products", "cards"]client.page.get("/products/cards")

Why double brackets [[...slug]] and not [...slug]? Single brackets require at least one segment — they break on the home page (/) because there's no slug. Double brackets make the param optional, so the home page works.

Types#


The starter includes two type files you'll use throughout this chapter and the rest of the course. Open them to get familiar — you won't need to edit them.

src/types/blog.ts — types for blog content:

  • BlogAuthorfirstName, lastName
  • BlogImageidPath, title (dotCMS image asset fields)
  • Blog — extends DotCMSBasicContentlet with urlTitle, urlMap, author, image, and description

src/types/page.ts — types for page-level data:

  • DotCMSPageNavigation — extends DotCMSNavigationItem with a children array for nested nav items
  • DotCMSPageContent — the shape of the content field in the page response: optional blogs and a navigation object

Fetch the page from dotCMS#


Rather than calling dotCMSClient.page.get() directly in every page, you'll use a small wrapper that adds React's cache() so repeated calls for the same path during a single render are deduplicated.

Create src/utils/getDotCMSPage.ts:

import { cache } from "react";
import { dotCMSClient } from "./dotCMSClient";
import type { DotCMSGraphQLParams } from "@dotcms/types";
import type { DotCMSPageContent } from "@/types/page";

export const getDotCMSPage = cache(
  async (path: string, graphql?: DotCMSGraphQLParams) => {
    try {
      const pageData = await dotCMSClient.page.get<{
        content: DotCMSPageContent;
      }>(path, graphql ? { graphql } : undefined);
      return pageData;
    } catch (e) {
      console.error("ERROR FETCHING PAGE: ", (e as Error).message);
      return null;
    }
  }
);

The generic type { content: DotCMSPageContent } tells TypeScript what shape to expect in the content field of the response — used for things like navigation data. The optional graphql parameter lets you pass GraphQL queries to fetch extra data alongsid[e the page (you'll use this in Chapter 5 for blog post bodies).

Fetch navigation for the header#


The header renders links from dotCMS. To get them, you'll use a GraphQL content query alongside the page request. Add a navigationQuery to src/utils/queries.ts:

export const navigationQuery = `
  DotNavigation(uri: "/", depth: 2) {
    href
    target
    title
    children {
      href
      target
      title
    }
  }
`;

This asks dotCMS for the site navigation tree starting at /, two levels deep. You'll pass it in the catch-all page to populate the header.

Before creating the catch-all route, delete the existing src/app/page.tsx from the starter — Next.js cannot have both a root page.tsx and an optional catch-all [[...slug]]/page.tsx at the same level and will throw a route conflict error on startup:

rm src/app/page.tsx

Now create the catch-all page at src/app/[[...slug]]/page.tsx:

import { notFound } from "next/navigation";
import { getDotCMSPage } from "@/utils/getDotCMSPage";
import { navigationQuery } from "@/utils/queries";
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 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 />}
    </>
  );
}

getDotCMSPage calls dotCMSClient.page.get(path) under the hood. It returns the page layout, containers, and every contentlet the author placed there.

The second argument { content: { navigation: navigationQuery } } passes a GraphQL query alongside the page request. dotCMS executes it in the same round trip and returns the result in pageContent.content — no separate API call needed. That's where navItems comes from.

The layout.header and layout.footer flags tell you whether this page type should render a header and footer. If dotCMS returns nothing for the path, notFound() shows a 404.

Render the page#


DotCMSLayoutBody from @dotcms/react does the heavy lifting — it reads the page layout and containers from the dotCMS response and renders each contentlet using the component you map to its content type.

Create src/views/Page.tsx:

"use client";

import { DotCMSLayoutBody } from "@dotcms/react";
import type { DotCMSComposedPageResponse, DotCMSPageResponse } from "@dotcms/types";
import { pageComponents } from "@/components/content-types";

interface PageProps {
  pageContent: DotCMSComposedPageResponse<DotCMSPageResponse>;
}

export function Page({ pageContent }: PageProps) {
  return (
    <main className="page-main">
      <DotCMSLayoutBody
        page={pageContent.pageAsset}
        components={pageComponents}
      />
    </main>
  );
}

Notice "use client" at the top — DotCMSLayoutBody is a client component because it handles visual editing interactions. Your data fetching stays in the server component (CatchAllPage), which passes the data down as props.

The component registry#


DotCMSLayoutBody needs to know which React component to render for each dotCMS content type. You provide that as a plain object — a registry that maps content type variable names to components.

Create src/components/content-types/index.tsx:

import { ComponentType } from "react";
import Banner from "./Banner";
import BlogCard from "./BlogCard";
import WebPageContent from "./WebPageContent";

export const pageComponents: Record<string, ComponentType<any>> = {
  Banner,
  Blog: BlogCard,
  webPageContent: WebPageContent,
};

When DotCMSLayoutBody encounters a contentlet with contentType: "Banner", it looks up Banner in this map and renders <Banner {...contentletData} />. The contentlet fields become the component props automatically.

If a content type isn't in the map, it renders nothing. You add entries here as your site grows.

Build the Banner component#


The home page has a Banner contentlet with these fields from dotCMS: title, caption, image, link, buttonText.

Create src/components/content-types/Banner.tsx:

import Link from "next/link";
import { DotCMSBasicContentlet } from "@dotcms/types";
import DotCMSImage, { type DotCMSImageSrc } from "@/components/DotCMSImage";

type BannerProps = DotCMSBasicContentlet & {
  title: string;
  caption: string;
  image?: DotCMSImageSrc;
  link?: string;
  buttonText?: string;
};

export default function Banner({ title, caption, image, link, buttonText }: BannerProps) {
  return (
    <section className="banner">
      <div className="banner__content">
        <h1>{title}</h1>
        <p>{caption}</p>
        {link && buttonText && (
          <Link href={link}>{buttonText}</Link>
        )}
      </div>
      {image && (
        <div className="banner__image">
          <DotCMSImage src={image} width={1200} height={500} alt={title || "Banner"} />
        </div>
      )}
    </section>
  );
}

What is DotCMSImage? Images in dotCMS are served through a URL pattern like /dA/{inode}/image/{filename}. DotCMSImage is a thin wrapper around Next.js Image that handles this URL format and connects to the dotCMS image CDN. It's already in your starter at src/components/DotCMSImage.tsx.

Build the BlogCard component#


The home page also has three Blog contentlets placed directly in a container row. dotCMS maps these to BlogCard in your registry.

Now create src/components/content-types/BlogCard.tsx:

import DotCMSImage from "@/components/DotCMSImage";
import Link from "next/link";
import type { Blog } from "@/types/blog";

export default function BlogCard(blog: Blog) {
  const { title, image, urlMap, modDate, urlTitle, description } = blog;

  return (
    <article className="blog-card">
      {image && (
        <Link href={urlMap || "#"}>
          <DotCMSImage
            src={image}
            alt={urlTitle || title || ""}
            fill={true}
          />
        </Link>
      )}
      <div className="blog-card__body">
        <a href={urlMap || "#"}>{title}</a>
        {description && <p>{description}</p>}
        <footer>
          {modDate && (
            <time>{new Date(modDate).toLocaleDateString("en-US")}</time>
          )}
        </footer>
      </div>
    </article>
  );
}

Build the WebPageContent component#


webPageContent is a generic rich text content type dotCMS uses for editable text blocks — like the section heading on the home page.

Create src/components/content-types/WebPageContent.tsx:

import { DotCMSBlockEditorRenderer } from "@dotcms/react";
import { DotCMSBasicContentlet } from "@dotcms/types";

import type { BlockEditorNode } from "@dotcms/types";

type WebPageContentProps = DotCMSBasicContentlet & {
  body?: BlockEditorNode;
};

function WebPageContent({ body }: WebPageContentProps) {
  return body ? (
    <div className="web-page-content">
      <DotCMSBlockEditorRenderer blocks={body} />
    </div>
  ) : null;
}

export default WebPageContent;

DotCMSBlockEditorRenderer takes the block editor JSON from dotCMS and renders it as HTML — headings, paragraphs, lists, and more.

Check your work#


Run npm run dev and open http://localhost:3000. You should see:

  • The hero banner with title, caption, image, and button from dotCMS
  • A section heading
  • Three blog post cards below it

The content author controls the page from dotCMS admin — your code just renders what dotCMS sends. In Chapter 6 you'll connect the visual editor so changes appear in real time without a refresh.

Styling: The components above use semantic class names like banner, blog-card, and page-main. The styles for these are already defined in src/app/globals.css using Tailwind's @apply — you don't need to write any CSS. Just use the class names shown and the styles apply automatically.

Checkpoint#


  • http://localhost:3000 shows the home page with the banner and blog cards
  • Editing content in dotCMS admin reflects on the page after refresh
  • No console errors

Next up

Chapter 3: Build the Home Page

Continue →