Build a Headless Site with dotCMS

Build the Blog Listing

In this chapter you'll build the blog listing page. It introduces two new concepts: fetching a collection of content with client.content.getCollection(), and letting a content author control what the listing shows — without touching code.

How the listing page works#


The blog listing page is driven by a widget placed in the page by a content author.

A quick distinction worth knowing: a contentlet is a piece of content — a blog post, a banner, a text block. A widget is a special content type that acts as configuration for the frontend. It doesn't contain the content to display — it contains instructions for fetching and displaying it. The BlogList widget is an example: it tells your app what to fetch and how to render it.

The starter already has a BlogList widget placed on the blog page. It has two fields:

  • Quantity — how many blog posts to show
  • Show — checkboxes for image, date, and description (image, date, description)

BlogList widget edit form in dotAdmin

These are the signals your frontend will read. The author configures the widget here; your Next.js code responds to whatever they set.

When your app calls client.page.get("/blog"), the page response includes the BlogList widget with those field values. You'll pass them to a server component that fetches the actual blog posts and renders them accordingly.

Server components and the slots pattern#


On the home page, DotCMSLayoutBody rendered everything automatically via the component registry. That works because the home page contentlets (Banner, Blog cards) contain all their data inline in the page response — no secondary fetch needed.

The blog listing is different, and this is where dotCMS's approach really shines. Instead of hardcoding "show 6 posts" in your code, a content author controls it: the BlogList widget placed on the page carries configuration — how many posts to show, which fields to display. Your app reads those values and fetches accordingly. The author can change the listing without touching a line of code.

But that fetch has to happen on the server. Any request from the browser would expose your dotCMS auth token in the network tab. Server components solve this cleanly — the fetch runs at request time on the server, the token never reaches the browser, and the rendered output is what gets sent to the client.

There's one constraint: DotCMSLayoutBody is a client component, and Next.js doesn't let you render a server component directly inside a client component. This is where buildSlots() comes in.

buildSlots() bridges the gap. It pre-renders your server components on the server and passes the results into DotCMSLayoutBody as slots — already-rendered output that the client component can place without running any server logic itself.

Server:
  BlogList (server component) → reads widget config, fetches posts, renders HTML
      ↓
  buildSlots() → packages the rendered output
      ↓
BlogListingPage (client component)
  DotCMSLayoutBody receives the slot → renders it in place of the BlogList contentlet

The result: the author configures the listing in dotCMS, the server fetches and renders the right data, and the client assembles the page — all without any browser-side API calls or hardcoded values in your code.

What you'll build#


  • Update BlogCard to respect the author's field visibility settings
  • Create BlogList — a server component that fetches posts using the widget config
  • Create BlogListView — a grid that renders the cards
  • Create BlogListingPage — the client view that wires slots into DotCMSLayoutBody
  • Create src/app/blog/page.tsx — the page that fetches, pre-renders, and assembles it all

Build the BlogList server component#


First update BlogCard to accept the show prop. Add BlogCardShow to the import and extend the props:

import DotCMSImage from "@/components/DotCMSImage";
import Link from "next/link";
import type { Blog } from "@/types/blog";
import type { BlogCardShow } from "@/components/content-types/BlogList";

export default function BlogCard(blog: Blog & { show?: BlogCardShow }) {
  const { title, image, urlMap, modDate, urlTitle, description, show } = blog;

  return (
    <article className="blog-card">
      {(show?.image ?? true) && image && (
        <Link href={urlMap || "#"}>
          <DotCMSImage src={image} alt={urlTitle || title || ""} fill={true} />
        </Link>
      )}
      <div className="blog-card__body">
        <a href={urlMap || "#"}>{title}</a>
        {(show?.description ?? true) && description && <p>{description}</p>}
        <footer>
          {(show?.date ?? true) && modDate && (
            <time>{new Date(modDate).toLocaleDateString("en-US")}</time>
          )}
        </footer>
      </div>
    </article>
  );
}

show?.image ?? true means: if show is provided, use its value — otherwise default to showing the field. On the home page where no show is passed, everything shows.

Now create src/components/BlogListView.tsx — a simple grid that renders a list of BlogCard components:

import BlogCard from "@/components/content-types/BlogCard";
import type { Blog } from "@/types/blog";
import type { BlogCardShow } from "@/components/content-types/BlogList";

interface BlogListViewProps {
  blogs?: Blog[];
  show?: BlogCardShow;
}

export default function BlogListView({ blogs = [], show }: BlogListViewProps) {
  return (
    <div className="blog-list">
      {blogs.map((blog) => (
        <BlogCard key={blog.identifier} {...blog} show={show} />
      ))}
      {blogs.length === 0 && <p className="blog-list__empty">No blogs available.</p>}
    </div>
  );
}

Now create the server component at src/components/content-types/BlogList.tsx:

import { dotCMSClient } from "@/utils/dotCMSClient";
import BlogListView from "@/components/BlogListView";
import type { Blog } from "@/types/blog";

export interface BlogCardShow {
  image: boolean;
  date: boolean;
  description: boolean;
}

export default async function BlogList(props: {
  quantity?: number;
  show?: string;
}) {
  const result = await dotCMSClient.content
    .getCollection<Blog>("Blog")
    .limit(props.quantity ?? 0);

  const show: BlogCardShow = {
    image: props.show?.includes("image") ?? true,
    date: props.show?.includes("date") ?? true,
    description: props.show?.includes("description") ?? true,
  };

  return <BlogListView blogs={result.contentlets} show={show} />;
}

Three things to notice:

  1. async function — this is a server component. It fetches data directly, no useEffect, no loading state.
  2. props.quantity comes from the BlogList contentlet in dotCMS — the author set it.
  3. props.show is a comma-separated string from dotCMS ("image,date,description"). You parse it into a BlogCardShow object and pass it down. show?.includes("image") ?? true defaults to true if the field isn't set.

Build the BlogListingPage view#


Create src/views/BlogListingPage.tsx:

"use client";

import { ReactNode } from "react";
import { DotCMSLayoutBody, useEditableDotCMSPage } from "@dotcms/react";
import type { DotCMSComposedPageResponse, DotCMSPageResponse } from "@dotcms/types";
import { pageComponents } from "@/components/content-types";

interface BlogListingPageProps {
  pageContent: DotCMSComposedPageResponse<DotCMSPageResponse>;
  slots?: Record<string, ReactNode>;
}

export function BlogListingPage({ pageContent, slots }: BlogListingPageProps) {

  return (
    <main className="page-main">
      <DotCMSLayoutBody
        page={pageContent.pageAsset}
        components={pageComponents}
        slots={slots}
      />
    </main>
  );
}

The key difference from Page.tsx is the slots prop on DotCMSLayoutBody. When it encounters a BlogList contentlet, instead of looking it up in pageComponents, it uses the pre-rendered slot you passed in.

Build the blog listing page#


The blog page only needs the page data and navigation — the same navigationQuery you already have in queries.ts. The blog posts themselves come from the BlogList server component, not from this fetch.

Create src/app/blog/page.tsx:

import { notFound } from "next/navigation";
import { buildSlots } from "@dotcms/react";
import { getDotCMSPage } from "@/utils/getDotCMSPage";
import { navigationQuery } from "@/utils/queries";
import { BlogListingPage } from "@/views/BlogListingPage";
import BlogList from "@/components/content-types/BlogList";
import Header from "@/components/Header";
import Footer from "@/components/Footer";

const PATH = "/blog";

export default async function BlogPage() {
  const pageContent = await getDotCMSPage(PATH, {
    content: { navigation: navigationQuery },
  });
  if (!pageContent) return notFound();

  const layout = pageContent.pageAsset?.layout;
  const navItems = pageContent.content?.navigation?.children ?? [];

  const slots = await buildSlots(pageContent.pageAsset.containers, {
    BlogList,
  });

  return (
    <>
      {layout?.header && <Header navItems={navItems} />}
      <BlogListingPage pageContent={pageContent} slots={slots} />
      {layout?.footer && <Footer />}
    </>
  );
}

Why doesn't /blog/page.tsx also catch detail URLs like /blog/post/my-post? Next.js matches the most specific route first. /blog/post/my-post matches src/app/blog/[...slug]/page.tsx before it ever reaches blog/page.tsx. You'll create that route in Chapter 5.

buildSlots() is async — it pre-renders the server components before passing them to the client component. It takes the containers from the page response and a map of component names to server components. It finds every BlogList contentlet in the containers, renders <BlogList quantity={...} show={...} /> with its field values as props, and returns the result as a slot keyed by the contentlet's identifier. DotCMSLayoutBody then renders each slot in the right position on the page.

Try it#


Run npm run dev and open http://localhost:3000/blog. You should see the blog listing with posts rendered according to whatever the author configured in the BlogList widget.

That's the pattern: the author configures, your code responds. In Chapter 6 you'll connect the visual editor so authors can change the widget config and see the listing update in real time — no refresh needed.

Checkpoint#


  • http://localhost:3000/blog shows the blog listing page
  • Blog posts render with the fields the author configured (image, date, description)
  • Changing the widget config in dotCMS admin changes what renders on the page

Next up

Chapter 5: Build the Blog Detail Page

Continue →
    Build a Headless Site with dotCMS · Ch. 4/10 — Build the Blog Listing | dotCMS Dev Site