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:
| 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.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}.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