Next.js is a great fit for a blog powered by a headless CMS. You get file-based routing, server components, static generation, metadata helpers, image optimization, and several ways to keep content fresh after publishing.
In this guide, we will build a simple blog with Next.js App Router and Marble as the headless CMS. The same structure works with any CMS that returns posts over an API, but Marble gives us a clean post API, authors, tags, cover images, rich HTML content, and webhooks when content changes.
By the end, you will have:
A blog index page that lists posts from your CMS.
A dynamic post page for each slug.
SEO metadata for each post.
A reusable component for rendering CMS HTML.
A simple path to revalidate content when posts change.
Prerequisites
Before you start, you need a Next.js project and a Marble workspace with at least one published post.
If you do not have a Marble workspace yet, create an account at app.marblecms.com. After signing in, open your workspace and create a few posts from the dashboard so the blog has something to render.
Next, create an API key. In the Marble dashboard, open your workspace, go to Settings, then choose API Keys under the Developers section.
From the API Keys page, create a key and copy it. For a read-only blog, a public/read key is usually enough. If your app will create, update, or delete content through the API, use a private key and keep it strictly on the server.
For this guide, add the key to .env.local:
MARBLE_API_KEY=your_api_key_hereDo not expose this key with a NEXT_PUBLIC_ prefix. The examples below fetch content from server components, so your key stays on the server.
What we are building
The example uses the Next.js App Router with a structure like this:
app/
blog/
page.tsx
[slug]/
page.tsx
api/
revalidate/
route.ts
lib/
marble.ts
components/
post-card.tsx
prose.tsxThe blog index will fetch posts from Marble. The dynamic route will fetch one post by slug. The revalidation route is optional, but useful if you want published changes in Marble to update your Next.js site without waiting for a full redeploy.
Create a Marble API client
First, install the Marble SDK:
npm install @usemarble/sdkNow create a small client in lib/marble.ts:
import { Marble } from "@usemarble/sdk";
if (!process.env.MARBLE_API_KEY) {
throw new Error("MARBLE_API_KEY is missing");
}
export const marble = new Marble({
apiKey: process.env.MARBLE_API_KEY,
});Next, add a few query helpers. Keeping these in one file makes your pages easier to read:
import { marble } from "@/lib/marble";
export async function getPosts() {
const response = await marble.posts.list();
return response.result.posts;
}
export async function getPost(slug: string) {
const response = await marble.posts.get({ identifier: slug });
return response.post;
}Build the blog index page
Create app/blog/page.tsx. Because this is a server component by default, it can fetch directly from Marble without shipping your API key to the browser.
import Link from "next/link";
import { getPosts } from "@/lib/marble/queries";
export const revalidate = 60;
export default async function BlogPage() {
const posts = await getPosts();
return (
<main className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-4xl font-semibold tracking-tight">
Blog
</h1>
<ul className="mt-10 space-y-8">
{posts.map((post) => (
<li key={post.id}>
<Link href={`/blog/${post.slug}`} className="block">
<h2 className="text-2xl font-medium">{post.title}</h2>
<p className="mt-2 text-zinc-600">
{post.description}
</p>
</Link>
</li>
))}
</ul>
</main>
);
}The revalidate export tells Next.js it can regenerate this route periodically. You can set a longer value for mostly static blogs, or use webhooks for faster updates. We have a separate guide on revalidating static Next.js pages with Marble webhooks if you want the full webhook setup.
Create the dynamic post page
Next, create app/blog/[slug]/page.tsx. This route receives a slug, fetches the matching post, and renders the post content.
import { notFound } from "next/navigation";
import { getPost } from "@/lib/marble/queries";
import { Prose } from "@/components/prose";
type PageProps = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound();
}
return (
<main className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-4xl font-semibold tracking-tight">
{post.title}
</h1>
<p className="mt-4 text-zinc-600">{post.description}</p>
<Prose html={post.content} className="mt-10" />
</main>
);
}In recent Next.js versions, route params are passed as a promise in App Router examples. If your project is on an older Next.js version, your params type may be a plain object instead.
Generate static post routes
If you want Next.js to generate known post pages at build time, add generateStaticParams to the dynamic post route:
import { getPosts } from "@/lib/marble/queries";
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}This pairs well with a CMS because the list of published slugs already lives in your content API. The Next.js docs for generateStaticParams explain the static generation behavior in more detail.
Add SEO metadata for each post
Next.js lets you export generateMetadata from a page when metadata depends on route params or fetched data.
import type { Metadata } from "next";
import { getPost } from "@/lib/marble/queries";
type PageProps = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return {};
}
return {
title: post.title,
description: post.description,
openGraph: {
type: "article",
title: post.title,
description: post.description,
publishedTime: new Date(post.publishedAt).toISOString(),
images: post.coverImage ? [post.coverImage] : [],
authors: post.authors.map((author) => author.name),
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.description,
images: post.coverImage ? [post.coverImage] : [],
},
};
}The Next.js metadata docs note that fetch requests inside generateMetadata are memoized across other server functions and components when the same data is requested.
Render CMS content safely
Most headless CMS content eventually renders as HTML. In React, that means using dangerouslySetInnerHTML. The name is dramatic for a reason: you should only render HTML you trust or HTML that your CMS sanitizes.
Create a small Prose component for long-form content:
type ProseProps = {
html: string;
className?: string;
};
export function Prose({ html, className }: ProseProps) {
return (
<article
className={`prose prose-zinc max-w-none ${className ?? ""}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}If you use Tailwind CSS, install the typography plugin and wrap your CMS content with prose. We have a separate guide on styling CMS content with Tailwind CSS.
Configure remote images
If your posts use cover images from Marble, configure next/image to allow Marble image hosts. In next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.marblecms.com",
},
],
},
};
export default nextConfig;You can then render cover images with next/image on the post page or inside a post card.
Keep content fresh with revalidation
There are a few ways to keep a Next.js blog fresh:
Use
export const revalidate = 60on routes where waiting up to a minute is fine.Call
revalidatePathfrom a route handler when Marble sends a webhook.Use
revalidateTagif your data fetching uses cache tags.
This guide keeps the revalidation section intentionally short because webhook verification deserves its own walkthrough. For the complete version, including signature verification and webhook payload handling, read How to revalidate static pages in Next.js with webhooks.
The Next.js revalidateTag docs are useful if you group multiple fetches under tags. The extended fetch docs explain how Next.js caching options work on the server.
Where the example template fits
If you want a fuller starting point, Marble has a Next.js example template that follows this same shape:
A Marble SDK client in
lib/marble/client.ts.Query helpers for posts and tags.
A homepage that lists posts.
Dynamic post pages with
generateStaticParamsandgenerateMetadata.A webhook route for revalidating content after updates.
The important idea is not the exact file structure. It is the boundary: your Next.js app fetches content on the server, renders stable pages for readers, and uses revalidation when CMS content changes.
Wrapping up
A headless CMS works well with Next.js because the responsibilities are clear. Marble manages your content, authors, media, tags, and webhooks. Next.js handles routing, rendering, caching, metadata, and deployment.
The simplest version is only a few pieces:
Create a CMS client.
Fetch posts for the blog index.
Fetch one post for each slug.
Render the HTML inside a styled content wrapper.
Add metadata and revalidation when you need it.
From there, you can add categories, tags, draft previews, syntax highlighting, search, related posts, or webhook-driven cache updates. Start with the boring version first. A blog that is easy to publish and easy to keep fresh is already most of the job.