Build a Headless Site with dotCMS
Build the Blog Detail Page
In this chapter you'll build the blog detail page. Each blog post lives at a URL like /blog/post/what-is-digital-banking — a dynamic route driven by the post's slug. You'll fetch the post data, render the title, author, date, featured image, and body content.
How the detail page differs from other pages#
On the home page and listing page, content lives in containers — the page layout holds contentlets, and DotCMSLayoutBody renders them.
Blog posts work differently. A blog post URL is mapped directly to a content item in dotCMS via a URL content map. When you fetch /blog/post/what-is-digital-banking, dotCMS returns the page asset — which can include containers and a layout — but it also includes urlContentMap, a special field that contains the blog post data directly.
In our starter the detail page doesn't have extra containers — it's just the article content. But dotCMS supports both at the same time: a detail page could have a banner at the top managed through containers, with the article body coming from urlContentMap. We're keeping it simple here.
This is why the blog detail page has its own dedicated route — it's better architecture. The catch-all handles generic pages; the detail route owns the blog post experience. Each route fetches exactly what it needs: the detail page uses a GraphQL query to request relationship fields like Blog → Author, which don't come back automatically in a standard page response. Keeping these concerns separate makes both routes simpler and easier to maintain.
The GraphQL query#
By default, dotCMSClient.page.get() returns basic page fields. To get the full blog post data — body, author, image — you need to ask for it explicitly using a GraphQL query.
The query is already defined in src/utils/queries.ts. Take a look:
export const blogDetailGraphQL = { page: ` urlContentMap { ... on Blog { title description modDate urlTitle body { json } author { firstName lastName image { idPath title } } image { idPath title } } } `, };
... on Blog is a GraphQL inline fragment — it tells dotCMS to return these fields only when the URL maps to a Blog content type.
Relationships without GraphQL#
author on a Blog is a relationship field — it points to a separate content item. Without GraphQL, the SDK returns only the related item's identifier. You'd have to make a second request to get the author's name and image.
With GraphQL you traverse relationships in a single query and go as deep as you need. Here, author returns the full author object, and author.image resolves the author's image relationship on top of that — all in one round trip.
Why body { json } and not just body#
In Chapter 3, WebPageContent typed body as BlockEditorNode directly — that's how the REST page API returns it. GraphQL wraps it: querying body { json } returns { json: BlockEditorNode }. This is why BlogURLContentMap uses body?: { json: BlockEditorNode } and you access it as body.json in the component.
You pass this query to getDotCMSPage and the response will include pageAsset.urlContentMap with all those fields populated.
Create the dynamic route#
Create src/app/blog/[...slug]/page.tsx:
import { notFound } from "next/navigation"; import { getDotCMSPage } from "@/utils/getDotCMSPage"; import { blogDetailGraphQL } from "@/utils/queries"; import { DetailPage } from "@/views/DetailPage"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; interface PageProps { params: Promise<{ slug: string[] }>; } export default async function BlogDetailPage({ params }: PageProps) { const { slug } = await params; const path = `/blog/${slug.join("/")}`; const pageContent = await getDotCMSPage(path, blogDetailGraphQL); if (!pageContent) return notFound(); const layout = pageContent.pageAsset?.layout; const navItems = pageContent.content?.navigation?.children ?? []; return ( <> {layout?.header && ( <Header navItems={navItems} /> )} <DetailPage pageContent={pageContent} /> {layout?.footer && <Footer />} </> ); }
slug is an array because the route is [...slug]. A URL like /blog/post/what-is-digital-banking gives you ["post", "what-is-digital-banking"]. Joining with / reconstructs the path dotCMS expects.
Add BlogURLContentMap to your types#
You defined Blog, BlogAuthor, and BlogImage in Chapter 3. Now add BlogURLContentMap to src/types/blog.ts — the shape of pageAsset.urlContentMap for blog posts:
import type { DotCMSURLContentMap, BlockEditorNode } from "@dotcms/types"; // add after the existing Blog interface // DotCMSBasicContentlet has body?: string, but blog returns a BlockEditorNode at runtime. // Use Omit to override body with the correct type. export type BlogURLContentMap = Omit<DotCMSURLContentMap, "body"> & { description?: string; publishDate?: number; body?: { json: BlockEditorNode }; image?: BlogImage; author?: BlogAuthor[]; };
The body field comes back as { json: BlockEditorNode } at runtime, not a plain string — hence the Omit override.
Build the DetailPage view#
Create src/views/DetailPage..tsx:
"use client"; import DotCMSImage from "@/components/DotCMSImage"; import { DotCMSBlockEditorRenderer } from "@dotcms/react"; import type { DotCMSComposedPageResponse, DotCMSPageResponse } from "@dotcms/types"; import type { BlogURLContentMap } from "@/types/blog"; interface DetailPageProps { pageContent: DotCMSComposedPageResponse<DotCMSPageResponse>; } export function DetailPage({ pageContent }: DetailPageProps) { const { title, image, body, publishDate } = (pageContent.pageAsset?.urlContentMap as BlogURLContentMap) ?? {}; return ( <main className="detail-page"> <article> {title && <h1>{title}</h1>} {publishDate && ( <time className="detail-page__date"> {new Date(publishDate).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", })} </time> )} {image && ( <div className="detail-page__image"> <DotCMSImage src={image} width={800} height={400} alt={title || ""} /> </div> )} {body?.json && ( <DotCMSBlockEditorRenderer blocks={body.json} className="detail-page__body" /> )} </article> </main> ); }
DotCMSBlockEditorRenderer takes the block editor JSON from dotCMS and renders it as HTML — headings, paragraphs, bullet lists, images, and more. No custom renderers needed for this course; the defaults handle everything in the starter content.
Try it#
Run npm run dev and click any blog card from the home page or listing page. You should land on a full blog post with:
- Title
- Author name
- Publish date
- Featured image
- Full article body
If you click a link that doesn't match any content in dotCMS, Next.js returns a 404 — handled by notFound().
Checkpoint#
-
/blog/post/what-is-digital-bankingrenders the full blog post - Title, author, date, image, and body all appear
- A non-existent URL returns a 404
- Links from the home page and listing page navigate to the correct detail pages
Next up
Chapter 6: Enable Visual Editing