Build a Headless Site with dotCMS

Build the Home Page

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.

Why does /blog/[...slug] exist as a separate route? The blog detail page needs different rendering logic — it uses a different part of the API response. Next.js matches the more specific route first, so /blog/my-post hits blog/[...slug]/page.tsx instead of the catch-all. You'll build that in Chapter 5.

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 />}
    </>
  );
}

dotCMSClient.page.get(path) fetches everything on that page from dotCMS: the layout, the containers, and every contentlet the author placed there. The layout.header and layout.footer flags come from dotCMS — they tell you whether this page type includes a header and footer. If dotCMS returns nothing for the path, you show 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>
  );
}

Note: In Chapter 4 you'll extend BlogCard to accept a show prop that lets a content author control which fields are visible. On the home page, all fields show by default.

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 4: Build the Blog Listing

Continue →