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.tsxThis single file handles every URL your dotCMS instance serves:
| URL | slug value | Fetches |
|---|---|---|
/ | undefined | client.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:
BlogAuthor—firstName,lastNameBlogImage—idPath,title(dotCMS image asset fields)Blog— extendsDotCMSBasicContentletwithurlTitle,urlMap,author,image, anddescription
src/types/page.ts — types for page-level data:
DotCMSPageNavigation— extendsDotCMSNavigationItemwith achildrenarray for nested nav itemsDotCMSPageContent— the shape of thecontentfield in the page response: optionalblogsand anavigationobject
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.tsxfrom the starter — Next.js cannot have both a rootpage.tsxand an optional catch-all[[...slug]]/page.tsxat the same level and will throw a route conflict error on startup:
rm src/app/page.tsxNow 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}.DotCMSImageis a thin wrapper around Next.jsImagethat handles this URL format and connects to the dotCMS image CDN. It's already in your starter atsrc/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, andpage-main. The styles for these are already defined insrc/app/globals.cssusing 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