Skip to main content

Headless Quickstart

Build a ClearCMS-powered site with your own frontend in 15 minutes.

This guide covers creating a Next.js app that fetches pages, blog posts, and design tokens from the ClearCMS API. By the end you'll have dynamic pages rendering ClearCMS sections, a blog listing, and visual editing via Bridge Mode.


Prerequisites

  • A ClearCMS site with at least one published page
  • An API key from Settings > Developer > API Keys
  • Node.js 18+
  • Basic familiarity with React and TypeScript
tip

Your site slug appears in your ClearCMS URL: https://your-site.clearcms.app. You'll use this throughout the guide.


1. Create your Next.js project

Scaffold a new Next.js app with TypeScript and the App Router:

npx create-next-app@latest my-site --typescript --app --eslint
cd my-site

Start the dev server to confirm everything works:

npm run dev

You should see the default Next.js page at http://localhost:3000.


2. Set up the API client

Create a small helper that wraps fetch with your ClearCMS base URL and API key header.

Create lib/clearcms.ts:

// lib/clearcms.ts

const BASE_URL = process.env.CLEARCMS_BASE_URL!;
const API_KEY = process.env.CLEARCMS_API_KEY!;

interface FetchOptions {
/** Include draft content (requires API key) */
draft?: boolean;
/** Additional query parameters */
params?: Record<string, string>;
/** Next.js cache/revalidation options */
next?: NextFetchRequestConfig;
}

/**
* Fetch from the ClearCMS API.
* Automatically includes the base URL and Authorization header.
*/
export async function clearcms<T = unknown>(
path: string,
options: FetchOptions = {}
): Promise<T> {
const url = new URL(path, BASE_URL);

if (options.draft) {
url.searchParams.set("draft", "true");
}

if (options.params) {
for (const [key, value] of Object.entries(options.params)) {
url.searchParams.set(key, value);
}
}

const res = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${API_KEY}`,
},
next: options.next ?? { revalidate: 60 },
});

if (!res.ok) {
throw new Error(`ClearCMS API error: ${res.status} ${res.statusText}`);
}

return res.json();
}

// ---------------------------------------------------------------------------
// Convenience helpers
// ---------------------------------------------------------------------------

/** Fetch a single page by slug, including its sections and globals. */
export async function getPage(slug: string) {
const res = await clearcms<{ data: ClearCMSPage }>(
`/api/v1/public/pages/${slug}`
);
return res.data;
}

/** Fetch a list of collection items (e.g. posts, team, products). */
export async function getCollection(
type: string,
params?: Record<string, string>
) {
const res = await clearcms<{
data: ClearCMSCollectionItem[];
pagination: ClearCMSPagination;
}>(`/api/v1/public/collections/${type}`, { params });
return res;
}

/** Fetch site-wide globals (site settings, navigation, branding). */
export async function getGlobals(key: "site" | "navigation" | "branding") {
return clearcms<Record<string, unknown>>(
`/api/v1/public/globals/${key}`
);
}

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface ClearCMSPage {
slug: string;
title: string;
seo: {
metaTitle?: string;
metaDescription?: string;
ogImage?: string;
noindex?: boolean;
};
sections: ClearCMSSection[];
globals: {
site: Record<string, unknown> | null;
branding: Record<string, unknown> | null;
navigation: {
navbarProps: Record<string, unknown> | null;
footerProps: Record<string, unknown> | null;
};
};
collections: Record<string, unknown[]>;
meta: {
id: string;
status: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
draft: boolean;
};
}

export interface ClearCMSSection {
id: string;
sectionId: string;
content: Record<string, unknown>;
styles?: Record<string, unknown>;
position: number;
}

export interface ClearCMSCollectionItem {
id: string;
type: string;
status: string;
fields: Record<string, unknown>;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
}

export interface ClearCMSPagination {
page: number;
per_page: number;
total: number;
total_pages: number;
}

Error handling

Failed requests return a JSON error:

{ "error": "NOT_FOUND", "message": "Page not found: about-us" }

Common codes: NOT_FOUND (404), UNAUTHORIZED (401), RATE_LIMITED (429). Handle them so your app can show proper error pages or retry:

try {
const page = await clearcms<{ data: ClearCMSPage }>(`/api/v1/public/pages/${slug}`);
return page.data;
} catch (err) {
if (err instanceof Error && err.message.includes("404")) {
// Page does not exist — show a 404 page
notFound();
}
// Re-throw unexpected errors
throw err;
}

Add your credentials to .env.local (never commit this file):

# .env.local
CLEARCMS_BASE_URL=https://your-site.clearcms.app
CLEARCMS_API_KEY=your_api_key_here

3. Fetch and render pages

Create a dynamic route that fetches any page by slug and renders its sections.

The section renderer

Each page contains an array of sections. Every section has a sectionId (e.g. hero, text-block, features-grid) and a content object with field values.

Create components/SectionRenderer.tsx:

// components/SectionRenderer.tsx
import type { ClearCMSSection } from "@/lib/clearcms";

// Import your section components
import { HeroSection } from "./sections/Hero";
import { TextBlockSection } from "./sections/TextBlock";
import { FeaturesSection } from "./sections/Features";
import { FallbackSection } from "./sections/Fallback";

/**
* Maps ClearCMS section IDs to React components.
* Add new entries here as you build more section components.
*/
const sectionComponents: Record<
string,
React.ComponentType<{ content: Record<string, unknown> }>
> = {
hero: HeroSection,
"text-block": TextBlockSection,
"features-grid": FeaturesSection,
};

export function SectionRenderer({
sections,
}: {
sections: ClearCMSSection[];
}) {
return (
<>
{sections.map((section) => {
const Component =
sectionComponents[section.sectionId] ?? FallbackSection;
return (
<Component
key={section.id}
content={section.content}
/>
);
})}
</>
);
}

Example section component

// components/sections/Hero.tsx
export function HeroSection({
content,
}: {
content: Record<string, unknown>;
}) {
return (
<section className="py-20 text-center">
<h1 className="text-5xl font-bold">
{content.headline as string}
</h1>
{content.subheadline && (
<p className="mt-4 text-xl text-gray-600">
{content.subheadline as string}
</p>
)}
{content.ctaLabel && (
<a
href={content.ctaUrl as string}
className="mt-8 inline-block rounded bg-blue-600 px-6 py-3 text-white"
>
{content.ctaLabel as string}
</a>
)}
</section>
);
}
// components/sections/TextBlock.tsx
export function TextBlockSection({
content,
}: {
content: Record<string, unknown>;
}) {
return (
<section className="prose mx-auto max-w-3xl py-12">
{content.heading && (
<h2>{content.heading as string}</h2>
)}
{content.body && (
<div
dangerouslySetInnerHTML={{
__html: content.body as string,
}}
/>
)}
</section>
);
}
// components/sections/Features.tsx
interface Feature {
title: string;
description: string;
icon?: string;
}

export function FeaturesSection({
content,
}: {
content: Record<string, unknown>;
}) {
const features = (content.features as Feature[]) ?? [];

return (
<section className="py-16">
{content.heading && (
<h2 className="mb-12 text-center text-3xl font-bold">
{content.heading as string}
</h2>
)}
<div className="mx-auto grid max-w-5xl grid-cols-1 gap-8 md:grid-cols-3">
{features.map((feature, i) => (
<div key={i} className="rounded-lg border p-6">
<h3 className="text-lg font-semibold">{feature.title}</h3>
<p className="mt-2 text-gray-600">{feature.description}</p>
</div>
))}
</div>
</section>
);
}
// components/sections/Fallback.tsx
export function FallbackSection({
content,
}: {
content: Record<string, unknown>;
}) {
if (process.env.NODE_ENV === "development") {
return (
<section className="border border-dashed border-yellow-400 bg-yellow-50 p-8 text-center text-sm text-yellow-700">
Unknown section type. Content keys:{" "}
{Object.keys(content).join(", ")}
</section>
);
}
return null;
}

The page route

// app/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPage } from "@/lib/clearcms";
import { SectionRenderer } from "@/components/SectionRenderer";
import type { Metadata } from "next";

interface PageProps {
params: Promise<{ slug: string }>;
}

export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { slug } = await params;

try {
const page = await getPage(slug);
return {
title: page.seo.metaTitle ?? page.title,
description: page.seo.metaDescription,
openGraph: page.seo.ogImage
? { images: [page.seo.ogImage] }
: undefined,
};
} catch {
return {};
}
}

export default async function CatchAllPage({
params,
}: PageProps) {
const { slug } = await params;

let page;
try {
page = await getPage(slug);
} catch {
notFound();
}

return (
<main>
<SectionRenderer sections={page.sections} />
</main>
);
}

For the homepage, add a similar file at app/page.tsx that fetches the home slug:

// app/page.tsx
import { getPage } from "@/lib/clearcms";
import { SectionRenderer } from "@/components/SectionRenderer";

export default async function HomePage() {
const page = await getPage("home");

return (
<main>
<SectionRenderer sections={page.sections} />
</main>
);
}

4. Load design tokens

ClearCMS exposes your site's colors, fonts, and spacing as CSS custom properties, keeping your frontend in sync with the ClearCMS editor.

Add the tokens stylesheet to your root layout:

// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<link
rel="stylesheet"
href={`${process.env.CLEARCMS_BASE_URL}/api/v1/public/tokens.css`}
/>
</head>
<body>{children}</body>
</html>
);
}

This endpoint is public (no API key needed), supports CORS, and is cached for 1 hour. It returns CSS custom properties on :root:

html:root {
--theme-primary: #2563eb;
--theme-text: #111827;
--theme-bg: #ffffff;
--theme-font-sans: Inter, sans-serif;
/* ... all active tokens */
}

Use them in your styles:

h1 {
color: var(--theme-primary);
font-family: var(--theme-font-heading);
}

.card {
border: 1px solid var(--theme-border);
background: var(--theme-bg-alt);
}

Or map them to your Tailwind config:

// tailwind.config.js
export default {
theme: {
extend: {
colors: {
primary: "var(--theme-primary)",
"primary-hover": "var(--theme-primary-hover)",
secondary: "var(--theme-secondary)",
},
fontFamily: {
sans: "var(--theme-font-sans)",
heading: "var(--theme-font-heading)",
},
},
},
};

See the full Design Tokens reference for all available tokens, the JSON endpoint, and how the token cascade works.


5. Render a blog listing

Content collections (blog posts, team members, products, etc.) are available through the collections endpoint. Here's a blog listing page:

// app/blog/page.tsx
import Link from "next/link";
import { getCollection } from "@/lib/clearcms";

export const metadata = {
title: "Blog",
};

export default async function BlogPage() {
const { data: posts, pagination } = await getCollection("posts", {
sort: "createdAt",
order: "desc",
per_page: "12",
});

return (
<main className="mx-auto max-w-4xl px-4 py-16">
<h1 className="mb-12 text-4xl font-bold">Blog</h1>

<div className="grid gap-8 md:grid-cols-2">
{posts.map((post) => (
<article
key={post.id}
className="rounded-lg border p-6"
>
{post.fields.featuredImage && (
<img
src={
typeof post.fields.featuredImage === "string"
? post.fields.featuredImage
: (post.fields.featuredImage as { url: string }).url
}
alt={post.fields.title as string}
className="mb-4 h-48 w-full rounded object-cover"
/>
)}
<h2 className="text-xl font-semibold">
<Link href={`/blog/${post.fields.slug ?? post.id}`}>
{post.fields.title as string}
</Link>
</h2>
{post.fields.excerpt && (
<p className="mt-2 text-gray-600">
{post.fields.excerpt as string}
</p>
)}
<time className="mt-4 block text-sm text-gray-400">
{new Date(
post.publishedAt ?? post.createdAt
).toLocaleDateString()}
</time>
</article>
))}
</div>

{pagination.total_pages > 1 && (
<p className="mt-8 text-center text-gray-500">
Page {pagination.page} of {pagination.total_pages}
</p>
)}
</main>
);
}

For individual post pages, create app/blog/[slug]/page.tsx and fetch a single item via the collection endpoint with a search filter, or use the authenticated content endpoint.


6. Enable visual editing (Bridge Mode)

Bridge Mode lets ClearCMS editors click elements in your frontend and edit them directly. Three steps:

1. Load the bridge script

Add the script tag to your layout:

// app/layout.tsx — add inside <head>
<script
src={`${process.env.CLEARCMS_BASE_URL}/bridge.js`}
defer
/>

The script is small and has no effect on visitors not logged into ClearCMS.

2. Add data attributes to editable elements

Mark elements with data-clearcms-field so the editor knows which fields they correspond to. Wrap them in a container with data-clearcms-page and data-clearcms-section:

<section
data-clearcms-page={page.meta.id}
data-clearcms-section={section.sectionId}
>
<h1 data-clearcms-field="headline">
{section.content.headline as string}
</h1>
<p data-clearcms-field="subheadline">
{section.content.subheadline as string}
</p>
</section>

3. That's it

Bridge Mode is auto-detected. When bridge.js loads and a valid ClearCMS session is present, editing controls appear automatically.

For field types, content item editing, and troubleshooting, see the Bridge Mode reference.


Next steps

You now have a headless ClearCMS site with dynamic pages, a blog, design tokens, and visual editing. Next steps:

  • Headless API — full endpoint reference for pages, content, and globals
  • Media API — upload and serve images, documents, and videos
  • Forms API — accept form submissions from your frontend
  • Design Tokens — JSON endpoint, Tailwind integration, and the token cascade
  • Bridge Mode — advanced field types, content item editing, and troubleshooting
Was this page helpful?