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:
| 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.
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:
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 />} </> ); }
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}.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> ); }
Note: In Chapter 4 you'll extend
BlogCardto accept ashowprop 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, 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 4: Build the Blog Listing