Learn how to integrate the CMS with your frontend application to fetch and display content.
Quick Start
1. Configure Environment
Set the API URL and the publishing environment for your deployment in .env.local:
.env.local
# .env.local (Next.js)NEXT_PUBLIC_API_URL=http://localhost:8080# For server-side rendering in DockerINTERNAL_API_URL=http://backend:8080# Publishing environment this deployment should fetch content from.# Use "development" for your dev site, "staging" for pre-production,# or "production" (the default) for the live site.CMS_ENVIRONMENT=production
The CMS ships with three environments out of the box: development, staging, and production. Each deployment of your frontend should point at the environment that matches its purpose. Content published to staging, for example, is only visible to requests that explicitly ask for staging content. When no environment is specified, the API defaults to production. See the Environments section for details on creating custom environments and the full publishing workflow.
2. Fetch Content
// Read the target environment from your .env.localconst environment = process.env.CMS_ENVIRONMENT || 'production';// Fetch published content from the delivery APIconst response = await fetch( `http://localhost:8080/api/delivery/blog-post?org=default&environment=${environment}`);const { items, total } = await response.json();
Subscribe to real-time content updates via Server-Sent Events. Authentication is performed via a JWT token passed as a query parameter, since the browser's EventSource API cannot send custom headers. The organization is derived from the token claims.
Event Types:
created -- New entry published
updated -- Entry updated
deleted -- Entry unpublished/deleted
React Integration
Installation
# From the integration directorycd integrationbun installbun run build
Pages built with the CMS visual editor are stored as JSON documents and served through the delivery API. Your frontend fetches this data and renders it using Puck's <Render> component. No custom page code is needed -- the layout and content are fully controlled from the CMS admin UI.
1. Install Puck
npm install @puckeditor/core
2. Define a Component Config
The component config tells Puck how to render each component type created in the visual editor. It must include a render function for every component used on your pages. Here is a minimal example with two components:
For the full built-in config with all 16 components, see frontend/src/lib/puck-config.tsx in the CMS repository.
3. Create the Renderer
Create a client component that wraps Puck's <Render>:
components/PuckPage.tsx
'use client';import { Render } from '@puckeditor/core';import '@puckeditor/core/puck.css';import { config } from '@/lib/puck-config';import type { Data } from '@puckeditor/core';interface PuckPageProps { data: Data | null;}export function PuckPage({ data }: PuckPageProps) { if (!data || !data.content?.length) return null; return <Render config={config} data={data} />;}
4. Fetch and Render a Page
Fetch the page entry from the delivery API and pass its page_content field to the renderer. Pages use the pages content type:
Some components (Container, Columns, CardGrid, ButtonGroup) contain nested child components via slots. In your config, slot fields use type: 'slot' and receive their children as a renderable component:
Instead of creating a dedicated route for every page, use a dynamic catch-all that automatically renders any CMS page by its slug. Any page created in the admin UI becomes available at /pages/{slug}:
Each page entry returned by the delivery API contains these fields:
Field
Type
Description
title
short_text
Page title (for your site header and SEO)
slug
short_text
URL identifier (e.g., "features", "about")
description
long_text
Meta description for SEO
page_content
page_builder
Puck JSON document -- pass this to <Render>
Available Components
The CMS visual editor ships with 16 components. Your frontend config must include a render function for each component type used on your pages:
Category
Components
Layout
Container, Columns, Spacer, Divider
Content
Heading, Text, Image, Video
Interactive
Button, ButtonGroup, Accordion
Composite
Hero, Card, CardGrid, CTABanner, Feature
Localized Pages
Pages support multi-language content. Each locale has its own entry linked in the CMS. Pass the locale parameter when fetching to get the correct translation:
// Fetch the German version of the features pageconst res = await fetch( 'https://your-cms.example.com/api/delivery/pages?org=default&locale=de', { cache: 'no-store' });const { items } = await res.json();const entry = items.find((item: any) => item.fields.slug === 'features');
Multi-Language Support
Fetching Localized Content
// Fetch content in a specific localeconst { items } = await fetchContentList<BlogPost>('blog-post', { locale: 'de'});
Preview content as it will appear at a specific date/time.
Using Preview Date
// Preview content as it will appear on a future dateconst previewDate = '2024-12-25T00:00:00Z';const { items } = await fetchContentList<Promotion>('promotion', { previewDate});
Environments let you publish content to separate stages such as development, staging, and production. Each entry independently tracks which environments it is published to, so you can review content on staging before promoting it to production.
Default Environments
Every new organization is provisioned with three environments automatically:
Name
Slug
Color
Description
Development
development
Indigo
For testing during development
Staging
staging
Amber
Pre-production testing
Production
production
Emerald
Live content (default)
The production environment is the default. All delivery API requests that omit an explicit environment will receive production content.
Creating Custom Environments
Admins can create additional environments (e.g., preview, qa) via the Management API:
Clone all published content from one environment to another
Publishing to an Environment
When publishing an entry, specify the target environment in the request body. If omitted, the entry is published to production.
# Publish to stagingcurl -X POST https://api.example.com/api/entries/{id}/publish \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "environment": "staging", "comment": "Ready for QA review" }'# Later, promote to productioncurl -X POST https://api.example.com/api/entries/{id}/publish \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "environment": "production", "comment": "Approved by QA" }'
An entry can be published to multiple environments simultaneously. Each publish creates a version snapshot in the audit history.
Unpublishing from an Environment
# Remove from staging only (production stays live)curl -X POST https://api.example.com/api/entries/{id}/unpublish \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "environment": "staging" }'
Cloning an Environment
You can clone all published content from one environment to another. This is useful for promoting an entire environment at once -- for example, copying everything from staging to production. The operation is idempotent: entries already published to the target environment are not duplicated.
In the admin UI, use the Clone Environment button on the Entries page to select a source and target environment. Only entries with published or scheduled status in the source environment are cloned. Draft and archived entries are not affected.
Fetching Content by Environment
Pass the environment query parameter (or X-CMS-Environment header) on delivery API requests to retrieve content published to a specific environment:
// Fetch staging contentconst { items } = await fetchContentList<BlogPost>('blog-post', { environment: 'staging',});// Or use the query parameter directlyconst res = await fetch( 'https://api.example.com/api/delivery/blog-post?org=default&environment=staging');
Typical Workflow
Edit -- Authors create or update content entries (saved as drafts).
Publish to development -- Developers verify the content renders correctly on the dev site.
Promote to staging -- Stakeholders review and approve the content on the staging site.
Promote to production -- The approved content goes live for end users.
Configuring Your Frontend
Point each deployment of your frontend at the appropriate environment by setting an environment variable:
.env.local
# Development deploymentCMS_ENVIRONMENT=development# Staging deployment# CMS_ENVIRONMENT=staging# Production deployment (or simply omit -- defaults to production)# CMS_ENVIRONMENT=production
With this setup, each deployment automatically fetches content from its corresponding environment without any code changes.
Dev Mode Overlay
The Dev Mode Overlay is a floating panel that provides controls for testing content under different conditions directly in the browser. It allows you to change the preview date (time travel), region, environment, and language without modifying code or redeploying.
The overlay is designed as a reusable component that integrating websites can drop into their dev or staging environments.
Quick Setup
Add the overlay to your root layout so it appears on every page:
app/layout.tsx
import { DevModeOverlay } from '@/components/DevModeOverlay';import { CMSProvider } from '@/lib/cms-provider';export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <CMSProvider> {children} <DevModeOverlay /> </CMSProvider> </body> </html> );}
The overlay stores its state in cookies so Server Components can read the overrides during SSR. Use getDevOverrides() and applyDevOverrides() to merge them into your fetch options:
app/page.tsx
import { fetchContentList } from '@/lib/cms';import { getLocale } from '@/lib/locale-server';import { getDevOverrides, applyDevOverrides } from '@/lib/dev-overrides';export default async function Page() { const locale = await getLocale(); const devOverrides = await getDevOverrides(); const opts = applyDevOverrides({ locale }, devOverrides); // opts now contains locale, plus any active overrides: // previewDate, region, environment const { items } = await fetchContentList<MyContent>('my-content', opts); return <div>{/* render items */}</div>;}
Cookie Reference
The overlay writes the following cookies (all optional, cleared when reset):
Cookie Name
Purpose
Example Value
cms_preview_date
Time travel -- ISO 8601 date sent as preview_date query param
2025-12-25T00:00:00.000Z
cms_region
Override geo-detection -- sent as region query param
europe
cms_environment
Content environment -- sent as environment query param
staging
locale
Language -- used by getLocale()
de
Conditional Rendering
To only show the overlay in non-production environments, conditionally render it based on an environment variable:
The management API enforces server-side validation on every POST /api/entries and PUT /api/entries/{id} request. Field values are checked against the content type schema: required fields, type correctness, min/max length for text, min/max value for numbers, and regex patterns.
Validation Error Response
When validation fails the API returns HTTP 400 with structured per-field errors:
{ "error": "Validation failed", "field_errors": [ { "field": "title", "message": "Title is required" }, { "field": "count", "message": "Count must be at least 0" } ]}
import axios from 'axios';async function createEntry(data: Partial<ContentEntry>) { try { const res = await api.post('/entries', data); return res.data; } catch (err) { if (axios.isAxiosError(err) && err.response?.status === 400) { const body = err.response.data as ValidationErrorResponse; if (body.field_errors) { // Map errors by field slug for display next to form inputs const errorMap: Record<string, string> = {}; for (const fe of body.field_errors) { errorMap[fe.field] = fe.message; } // Use errorMap in your form state to show inline errors setFieldErrors(errorMap); return null; } } throw err; }}
Supported Validation Rules
Rule
Applies to
Description
required
All field types
Field must be present and non-empty
min_length
short_text, long_text, rich_text
Minimum character length
max_length
short_text, long_text, rich_text
Maximum character length
min
integer, decimal
Minimum numeric value
max
integer, decimal
Maximum numeric value
regex
short_text, long_text
Value must match the pattern
Rules are configured on the content type's field definitions via the settings and validations properties.
Responsive Media
The responsive_media field type allows you to store per-breakpoint media URLs in a single field. This enables serving optimized images for desktop, tablet, and mobile devices -- reducing page load times and improving the user experience.
Data Structure
A responsive media field stores a JSON object with breakpoint keys. The default key is required (desktop / full-size), while tablet and mobile are optional overrides:
/** Per-breakpoint media URLs. "default" is always required. */interface ResponsiveMedia { default: string; tablet?: string; mobile?: string;}interface HeroSection { title: string; description: string; image_url?: string; // legacy single URL image_url_responsive?: ResponsiveMedia; // responsive variant}/** * Resolve a responsive media value with a legacy fallback. * Returns null when neither is available. */function resolveResponsiveMedia( responsive?: ResponsiveMedia | null, fallbackUrl?: string,): ResponsiveMedia | null { if (responsive && typeof responsive === 'object' && responsive.default) { return responsive; } if (fallbackUrl) { return { default: fallbackUrl }; } return null;}
ResponsiveImage Component
Use a <picture> element to let the browser automatically choose the best source for the current viewport width. The breakpoints are: mobile < 640px, tablet 640-1023px, desktop ≥ 1024px.
The API validates responsive_media fields on every save. The rules are:
The value must be an object (not a string or array)
A default key with a non-empty string URL is required
Optional keys: tablet, mobile -- must be strings when present
Unknown breakpoint keys are rejected
Content Type Schema
When defining a content type, use responsive_media as the field type. You can pair it with a legacy short_text media URL field for backwards compatibility:
The CMS provides comprehensive SEO support at both the content type and entry level. Editors manage SEO metadata through the admin UI, and frontends consuming the delivery API benefit automatically.
Per-Entry SEO Metadata
Every content entry can include an seo object with overrides for search engine and social media metadata. These fields are editable via the SEO panel in the entry editor:
{ "id": "abc123", "fields": { "title": "My Page", "slug": "my-page" }, "seo": { "meta_title": "My Page | Custom Title", "meta_description": "A concise description for search engines", "og_title": "My Page on Facebook", "og_description": "Description for Open Graph", "og_image": "https://example.com/og-image.jpg", "twitter_title": "My Page on Twitter", "twitter_description": "Description for Twitter Cards", "twitter_image": "https://example.com/twitter-image.jpg", "canonical_url": "/pages/my-page", "no_index": false, "no_follow": false, "sitemap_priority": 0.8, "sitemap_change_frequency": "weekly", "exclude_from_sitemap": false }}
Structured Data (JSON-LD)
Content types can be configured with a structured data type (e.g., Article, FAQPage, Product) and a field mapping. The delivery API automatically generates JSON-LD:
The CMS provides a redirect management system for handling URL changes. Redirects can be created manually, imported via CSV, or auto-generated when entry slugs change.
When an entry's slug is changed, the CMS automatically creates a 301 redirect from the old URL to the new URL. These redirects are marked with auto_created: true.
import { NextResponse, type NextRequest } from 'next/server';interface Redirect { source_path: string; target_path: string; status_code: number; is_regex?: boolean;}let cachedRedirects: Redirect[] | null = null;async function getRedirects(): Promise<Redirect[]> { if (cachedRedirects) return cachedRedirects; try { const res = await fetch( 'http://localhost:8080/api/delivery/redirects?org=default' ); const data = await res.json(); cachedRedirects = data.redirects || []; // Refresh every 60s setTimeout(() => { cachedRedirects = null; }, 60_000); return cachedRedirects!; } catch { return []; }}export async function middleware(request: NextRequest) { const redirects = await getRedirects(); const match = redirects.find(r => r.source_path === request.nextUrl.pathname); if (match) { return NextResponse.redirect( new URL(match.target_path, request.url), match.status_code as 301 | 302 ); }}
Sitemap
The CMS generates XML sitemaps dynamically from published content. Content types with include_in_sitemap: true in their SEO settings are included.
Backend Sitemap API
GET /api/delivery/sitemap.xml?org={orgSlug}&base_url={url}
Returns a standard XML sitemap. Per-entry overrides (exclude_from_sitemap, no_index, custom priority/frequency) are respected. The base_url parameter sets the URL prefix for all entries.
The frontend's sitemap.ts dynamically fetches content types and their entries, combining static marketing routes with CMS-managed content. It respects all SEO settings at both the content type and entry level.
app/sitemap.ts
import type { MetadataRoute } from 'next';import { fetchContentList } from '@/lib/cms';export default async function sitemap(): Promise<MetadataRoute.Sitemap> { // Static routes const staticRoutes = [ { url: siteUrl, changeFrequency: 'weekly', priority: 1.0 }, // ... ]; // Fetch content types with SEO settings const contentTypes = await fetchSitemapContentTypes(); const sitemapTypes = contentTypes.filter( ct => ct.seo_settings?.include_in_sitemap ); // Build dynamic routes from CMS content const dynamicRoutes = []; for (const ct of sitemapTypes) { const { items } = await fetchContentList(ct.slug); for (const item of items) { if (item.seo?.exclude_from_sitemap || item.seo?.no_index) continue; dynamicRoutes.push({ url: resolveURL(ct.seo_settings.url_pattern, item.fields), lastModified: new Date(item.updated_at), priority: item.seo?.sitemap_priority ?? ct.seo_settings.default_sitemap_priority, changeFrequency: item.seo?.sitemap_change_frequency ?? ct.seo_settings.default_change_frequency, }); } } return [...staticRoutes, ...dynamicRoutes];}
Best Practices
Use TypeScript -- Define interfaces for your content types for type safety
Cache Appropriately -- Use revalidate for ISR in Next.js
Handle Errors -- Always provide fallback content for critical sections
Sort by Position -- Use position fields for ordered content
Filter on Server -- Use locale and other filters in the API call, not client-side
Minimize Requests -- Batch related content fetches with Promise.all
Use SSR for SEO -- Fetch marketing content server-side for search engines
Preview Before Publish -- Use time travel to verify scheduled content
Optimize Media -- Use responsive_media fields to serve smaller images on mobile