CMS

Integration Guide

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 Docker
INTERNAL_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.local
const environment = process.env.CMS_ENVIRONMENT || 'production';

// Fetch published content from the delivery API
const response = await fetch(
  `http://localhost:8080/api/delivery/blog-post?org=default&environment=${environment}`
);
const { items, total } = await response.json();

3. Display Content

items.forEach(entry => {
  console.log(entry.fields.title);
  console.log(entry.fields.content);
});

API Reference

Delivery API Endpoints

The delivery API provides public, read-only access to published content.

List Published Entries

GET /api/delivery/{contentType}?org={orgSlug}

Parameters:

ParameterTypeRequiredDescription
contentTypestringYesThe slug of the content type (e.g., blog-post)
orgstringYesOrganization slug (query param or X-CMS-Org header)
localestringNoFilter by locale (e.g., en, de)
environmentstringNoEnvironment slug (e.g., production, staging). Defaults to production. Also accepted via X-CMS-Environment header.
preview_datestringNoISO 8601 date for time travel preview

Response:

{
  "items": [
    {
      "id": "abc123",
      "content_type_id": "def456",
      "locale": "en",
      "fields": {
        "title": "My Blog Post",
        "content": "Lorem ipsum..."
      },
      "status": "published",
      "version": 3,
      "created_at": "2024-01-15T10:30:00Z",
      "updated_at": "2024-01-20T14:45:00Z",
      "published_at": "2024-01-20T14:45:00Z"
    }
  ],
  "total": 1
}

Get Single Entry

GET /api/delivery/{contentType}/{id}

Parameters:

ParameterTypeRequiredDescription
contentTypestringYesThe slug of the content type
idstringYesThe entry ID
environmentstringNoEnvironment slug. Defaults to production. Also accepted via X-CMS-Environment header.
preview_datestringNoISO 8601 date for time travel preview

Response:

{
  "id": "abc123",
  "content_type_id": "def456",
  "locale": "en",
  "fields": {
    "title": "My Blog Post",
    "content": "Lorem ipsum..."
  },
  "status": "published",
  "version": 3,
  "created_at": "2024-01-15T10:30:00Z",
  "updated_at": "2024-01-20T14:45:00Z",
  "published_at": "2024-01-20T14:45:00Z"
}

Real-Time Events (SSE)

GET /api/content/events?token={jwt}

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 directory
cd integration
bun install
bun run build

CMSProvider Setup

Wrap your application with the CMSProvider:

import { CMSProvider } from '@cms/react';

function App() {
  return (
    <CMSProvider config={{
      apiUrl: 'https://api.example.com',
      orgSlug: 'my-org',
      defaultLocale: 'en'
    }}>
      <MyApp />
    </CMSProvider>
  );
}

useContentList Hook

Fetch a list of entries:

import { useContentList } from '@cms/react';

interface BlogPost {
  title: string;
  excerpt: string;
  author: string;
  published_date: string;
}

function BlogList() {
  const { items, isLoading, error } = useContentList<BlogPost>('blog-post');

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading posts</div>;

  return (
    <ul>
      {items.map(post => (
        <li key={post.id}>
          <h2>{post.fields.title}</h2>
          <p>{post.fields.excerpt}</p>
          <span>By {post.fields.author}</span>
        </li>
      ))}
    </ul>
  );
}

useContent Hook

Fetch a single entry:

import { useContent } from '@cms/react';

interface BlogPost {
  title: string;
  content: string;
  author: string;
}

function BlogPost({ id }: { id: string }) {
  const { data, isLoading, error } = useContent<BlogPost>('blog-post', id);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading post</div>;
  if (!data) return <div>Post not found</div>;

  return (
    <article>
      <h1>{data.fields.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: data.fields.content }} />
      <footer>By {data.fields.author}</footer>
    </article>
  );
}

Server-Side Rendering (Next.js)

For SEO and performance, fetch content on the server.

Using the CMS Helpers

lib/cms.ts
import { fetchContentList, fetchContent, fetchHeroSection, fetchFeatures } from '@/lib/cms';

// In a Server Component
export default async function BlogPage() {
  const { items } = await fetchContentList<BlogPost>('blog-post', {
    locale: 'en',
    revalidate: 60, // Revalidate every 60 seconds
  });

  return (
    <div>
      {items.map(post => (
        <article key={post.id}>
          <h2>{post.fields.title}</h2>
        </article>
      ))}
    </div>
  );
}

Custom Fetch Function

async function getContent<T>(contentType: string, locale?: string): Promise<T[]> {
  const apiUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL;
  const params = new URLSearchParams({ org: 'default' });
  if (locale) params.set('locale', locale);

  const res = await fetch(`${apiUrl}/api/delivery/${contentType}?${params}`, {
    next: { revalidate: 60 }
  });

  if (!res.ok) return [];
  const data = await res.json();
  return data.items;
}

Dynamic Routes

app/blog/[slug]/page.tsx
import { fetchContentList, fetchContent } from '@/lib/cms';

interface BlogPost {
  title: string;
  slug: string;
  content: string;
}

// Generate static params for all blog posts
export async function generateStaticParams() {
  const { items } = await fetchContentList<BlogPost>('blog-post');
  return items.map(post => ({ slug: post.fields.slug }));
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const { items } = await fetchContentList<BlogPost>('blog-post');
  const post = items.find(p => p.fields.slug === params.slug);

  if (!post) return <div>Post not found</div>;

  return (
    <article>
      <h1>{post.fields.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.fields.content }} />
    </article>
  );
}

Client-Side Fetching

For dynamic content that does not need SEO:

'use client';

import { useState, useEffect } from 'react';

interface Notification {
  title: string;
  message: string;
}

function Notifications() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchNotifications() {
      const res = await fetch('/api/delivery/notification?org=default');
      const data = await res.json();
      setNotifications(data.items.map((n: any) => n.fields));
      setLoading(false);
    }
    fetchNotifications();
  }, []);

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      {notifications.map((n, i) => (
        <div key={i}>
          <strong>{n.title}</strong>
          <p>{n.message}</p>
        </div>
      ))}
    </div>
  );
}

TypeScript Types

Content Entry Type

interface ContentEntry<T = Record<string, unknown>> {
  id: string;
  content_type_id: string;
  locale: string;
  fields: T;
  status: 'draft' | 'published' | 'archived' | 'scheduled';
  version: number;
  created_at: string;
  updated_at: string;
  published_at?: string;
  seo?: SEOMetadata;              // Per-entry SEO overrides
  structured_data?: Record<string, unknown>; // Auto-generated JSON-LD
  alternates?: EntryAlternate[];  // Hreflang locale alternates
}

interface SEOMetadata {
  meta_title?: string;
  meta_description?: string;
  og_title?: string;
  og_description?: string;
  og_image?: string;
  twitter_title?: string;
  twitter_description?: string;
  twitter_image?: string;
  canonical_url?: string;
  no_index?: boolean;
  no_follow?: boolean;
  sitemap_priority?: number;
  sitemap_change_frequency?: string;
  exclude_from_sitemap?: boolean;
}

interface EntryAlternate {
  locale: string;
  slug: string;
  url: string;
}

Define Your Content Types

types/content.ts
export interface BlogPost {
  title: string;
  slug: string;
  excerpt: string;
  content: string;
  author: string;
  featured_image?: string;
  published_date: string;
  tags: string[];
}

export interface Product {
  name: string;
  description: string;
  price: number;
  sku: string;
  images: string[];
  in_stock: boolean;
}

export interface FAQ {
  question: string;
  answer: string;
  category: string;
  position: number;
}

Type-Safe Fetching

import { ContentEntry } from '@/lib/cms';
import { BlogPost } from '@/types/content';

async function getBlogPosts(): Promise<ContentEntry<BlogPost>[]> {
  const { items } = await fetchContentList<BlogPost>('blog-post');
  return items;
}

Common Patterns

Navigation Menu

interface NavItem {
  label: string;
  href: string;
  position: number;
}

async function Navigation() {
  const { items } = await fetchContentList<NavItem>('nav-item');
  const sortedItems = items.sort((a, b) => a.fields.position - b.fields.position);

  return (
    <nav>
      {sortedItems.map(item => (
        <a key={item.id} href={item.fields.href}>
          {item.fields.label}
        </a>
      ))}
    </nav>
  );
}

Feature Grid

interface Feature {
  title: string;
  description: string;
  icon: string;
  position: number;
}

async function FeatureGrid() {
  const { items } = await fetchContentList<Feature>('feature');
  const features = items
    .map(f => f.fields)
    .sort((a, b) => a.position - b.position);

  return (
    <div className="grid grid-cols-3 gap-4">
      {features.map((feature, i) => (
        <div key={i} className="p-4 border rounded">
          <span className="text-2xl">{feature.icon}</span>
          <h3>{feature.title}</h3>
          <p>{feature.description}</p>
        </div>
      ))}
    </div>
  );
}

FAQ Accordion

interface FAQ {
  question: string;
  answer: string;
  position: number;
}

async function FAQSection() {
  const { items } = await fetchContentList<FAQ>('faq');
  const faqs = items
    .map(f => f.fields)
    .sort((a, b) => a.position - b.position);

  return (
    <div className="space-y-4">
      {faqs.map((faq, i) => (
        <details key={i} className="border rounded p-4">
          <summary className="font-semibold cursor-pointer">
            {faq.question}
          </summary>
          <p className="mt-2 text-gray-600">{faq.answer}</p>
        </details>
      ))}
    </div>
  );
}

Pricing Table

interface PricingTier {
  name: string;
  price: string;
  period: string;
  description: string;
  features: string; // JSON array
  cta_text: string;
  highlighted: boolean;
  position: number;
}

function parseFeatures(json: string): string[] {
  try {
    return JSON.parse(json);
  } catch {
    return [];
  }
}

async function PricingTable() {
  const { items } = await fetchContentList<PricingTier>('pricing-tier');
  const tiers = items
    .map(t => t.fields)
    .sort((a, b) => a.position - b.position);

  return (
    <div className="grid grid-cols-3 gap-6">
      {tiers.map((tier, i) => (
        <div
          key={i}
          className={`p-6 border rounded ${tier.highlighted ? 'border-blue-500 border-2' : ''}`}
        >
          <h3 className="text-xl font-bold">{tier.name}</h3>
          <div className="text-3xl font-bold mt-2">
            {tier.price}
            {tier.period && <span className="text-sm">{tier.period}</span>}
          </div>
          <p className="text-gray-600 mt-2">{tier.description}</p>
          <ul className="mt-4 space-y-2">
            {parseFeatures(tier.features).map((feature, j) => (
              <li key={j}>{feature}</li>
            ))}
          </ul>
          <button className="mt-4 w-full py-2 bg-blue-500 text-white rounded">
            {tier.cta_text}
          </button>
        </div>
      ))}
    </div>
  );
}

Visual Editor (Puck)

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:

lib/puck-config.tsx
import type { Config, Slot } from '@puckeditor/core';

type HeadingProps = { text: string; level: string; align: string };
type ContainerProps = { content: Slot; maxWidth: string; paddingY: number };

type Components = {
  Heading: HeadingProps;
  Container: ContainerProps;
};

export const config: Config<Components> = {
  components: {
    Container: {
      label: 'Container',
      fields: {
        content: { type: 'slot' },  // Accepts child components
        maxWidth: {
          type: 'select', label: 'Max Width',
          options: [
            { label: 'Medium (768px)', value: '768px' },
            { label: 'Large (1200px)', value: '1200px' },
          ],
        },
        paddingY: { type: 'number', label: 'Vertical Padding (px)' },
      },
      defaultProps: { maxWidth: '1200px', paddingY: 32, content: [] },
      render: ({ content: Content, maxWidth, paddingY }) => (
        <div style={{ maxWidth, margin: '0 auto', padding: `${paddingY}px 24px` }}>
          <Content />
        </div>
      ),
    },

    Heading: {
      label: 'Heading',
      fields: {
        text: { type: 'text', label: 'Text' },
        level: {
          type: 'select', label: 'Level',
          options: [
            { label: 'H1', value: 'h1' },
            { label: 'H2', value: 'h2' },
            { label: 'H3', value: 'h3' },
          ],
        },
        align: {
          type: 'radio', label: 'Alignment',
          options: [
            { label: 'Left', value: 'left' },
            { label: 'Center', value: 'center' },
          ],
        },
      },
      defaultProps: { text: 'Heading', level: 'h2', align: 'left' },
      render: ({ text, level, align }) => {
        const Tag = level as keyof React.JSX.IntrinsicElements;
        return <Tag style={{ textAlign: align as any }}>{text}</Tag>;
      },
    },
  },
};

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:

app/features/page.tsx
import { PuckPage } from '@/components/PuckPage';

export const dynamic = 'force-dynamic';

export default async function FeaturesPage() {
  const res = await fetch(
    'https://your-cms.example.com/api/delivery/pages?org=default&locale=en',
    { cache: 'no-store' }
  );
  const { items } = await res.json();
  const entry = items.find((item: any) => item.fields.slug === 'features');

  if (!entry) return <div>Page not available</div>;

  return <PuckPage data={entry.fields.page_content || null} />;
}

Rendering Slot-Based Components

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:

Columns: {
  fields: {
    columnA: { type: 'slot' },
    columnB: { type: 'slot' },
    columnC: { type: 'slot' },
    columns: { type: 'radio', label: 'Columns', options: [/*...*/] },
    gap: { type: 'number', label: 'Gap (px)' },
  },
  defaultProps: { columns: '2', gap: 24, columnA: [], columnB: [], columnC: [] },
  render: ({ columns, gap, columnA: ColA, columnB: ColB, columnC: ColC }) => (
    <div style={{
      display: 'grid',
      gridTemplateColumns: `repeat(${columns}, 1fr)`,
      gap,
    }}>
      <ColA />
      <ColB />
      {columns === '3' && <ColC />}
    </div>
  ),
},

Catch-All Page Route (Optional)

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}:

app/pages/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { PuckPage } from '@/components/PuckPage';

export const dynamic = 'force-dynamic';

export default async function VisualPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const res = await fetch(
    `https://your-cms.example.com/api/delivery/pages?org=default&locale=en`,
    { cache: 'no-store' }
  );
  const { items } = await res.json();
  const entry = items.find((item: any) => item.fields.slug === slug);

  if (!entry) return notFound();

  return <PuckPage data={entry.fields.page_content || null} />;
}

Page Data Structure

Each page entry returned by the delivery API contains these fields:

FieldTypeDescription
titleshort_textPage title (for your site header and SEO)
slugshort_textURL identifier (e.g., "features", "about")
descriptionlong_textMeta description for SEO
page_contentpage_builderPuck 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:

CategoryComponents
LayoutContainer, Columns, Spacer, Divider
ContentHeading, Text, Image, Video
InteractiveButton, ButtonGroup, Accordion
CompositeHero, 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 page
const 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 locale
const { items } = await fetchContentList<BlogPost>('blog-post', {
  locale: 'de'
});

Language Switcher

'use client';

import { useCMS } from '@/lib/cms-provider';

function LanguageSwitcher() {
  const { locale, setLocale } = useCMS();

  return (
    <select value={locale} onChange={e => setLocale(e.target.value)}>
      <option value="en">English</option>
      <option value="de">Deutsch</option>
      <option value="fr">Francais</option>
    </select>
  );
}

Server-Side Locale Detection

app/[locale]/page.tsx
export default async function LocalizedPage({
  params
}: {
  params: { locale: string }
}) {
  const { items } = await fetchContentList<BlogPost>('blog-post', {
    locale: params.locale
  });

  return (
    <div>
      {items.map(post => (
        <article key={post.id}>
          <h2>{post.fields.title}</h2>
        </article>
      ))}
    </div>
  );
}

Time Travel Preview

Preview content as it will appear at a specific date/time.

Using Preview Date

// Preview content as it will appear on a future date
const previewDate = '2024-12-25T00:00:00Z';

const { items } = await fetchContentList<Promotion>('promotion', {
  previewDate
});

Preview Mode Component

'use client';

import { useCMS } from '@/lib/cms-provider';

function PreviewBar() {
  const { isPreview, previewDate, setPreviewDate } = useCMS();

  if (!isPreview) return null;

  return (
    <div className="bg-yellow-100 p-2 text-center">
      Previewing content as of: {previewDate}
      <button onClick={() => setPreviewDate(null)} className="ml-4 underline">
        Exit Preview
      </button>
    </div>
  );
}

Time Travel UI

'use client';

import { useState } from 'react';
import { useCMS } from '@/lib/cms-provider';

function TimeTravelPicker() {
  const { setPreviewDate } = useCMS();
  const [date, setDate] = useState('');

  const handlePreview = () => {
    if (date) {
      setPreviewDate(new Date(date).toISOString());
    }
  };

  return (
    <div className="flex gap-2">
      <input
        type="datetime-local"
        value={date}
        onChange={e => setDate(e.target.value)}
        className="border rounded px-2"
      />
      <button onClick={handlePreview} className="bg-blue-500 text-white px-4 rounded">
        Preview
      </button>
    </div>
  );
}

Environments

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:

NameSlugColorDescription
DevelopmentdevelopmentIndigoFor testing during development
StagingstagingAmberPre-production testing
ProductionproductionEmeraldLive 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:

curl -X POST https://api.example.com/api/environments \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "QA",
    "slug": "qa",
    "description": "Quality assurance environment",
    "color": "#8b5cf6"
  }'

Environment Management API

MethodEndpointAuthDescription
GET/api/environmentsAny roleList all environments for the organization
POST/api/environmentsAdminCreate a new environment
GET/api/environments/{id}Any roleGet a single environment
PUT/api/environments/{id}AdminUpdate an environment
DELETE/api/environments/{id}AdminDelete an environment (cannot delete the default)
POST/api/environments/clonePublish permissionClone 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 staging
curl -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 production
curl -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.

# Clone all staging content to production
curl -X POST https://api.example.com/api/environments/clone \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "source_environment": "staging", "target_environment": "production" }'

# Response
# {
#   "message": "Environment cloned successfully",
#   "count": 12,
#   "source_environment": "staging",
#   "target_environment": "production"
# }

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 content
const { items } = await fetchContentList<BlogPost>('blog-post', {
  environment: 'staging',
});

// Or use the query parameter directly
const res = await fetch(
  'https://api.example.com/api/delivery/blog-post?org=default&environment=staging'
);

Typical Workflow

  1. Edit -- Authors create or update content entries (saved as drafts).
  2. Publish to development -- Developers verify the content renders correctly on the dev site.
  3. Promote to staging -- Stakeholders review and approve the content on the staging site.
  4. 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 deployment
CMS_ENVIRONMENT=development

# Staging deployment
# CMS_ENVIRONMENT=staging

# Production deployment (or simply omit -- defaults to production)
# CMS_ENVIRONMENT=production

Then read it when fetching content:

lib/cms.ts
const environment = process.env.CMS_ENVIRONMENT || 'production';

export async function getContent<T>(contentType: string, locale?: string) {
  const params = new URLSearchParams({ org: 'default' });
  if (locale) params.set('locale', locale);
  params.set('environment', environment);

  const res = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}/api/delivery/${contentType}?${params}`,
    { next: { revalidate: 60 } }
  );

  if (!res.ok) return { items: [], total: 0 };
  return res.json();
}

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>
  );
}

Configuration Props

PropTypeDefaultDescription
localesstring[]['en', 'de']Available language codes for the locale switcher
environmentsstring[]['production', 'staging', 'development']Available environment slugs
regionsstring[]['', 'americas', 'europe', 'asia', ...]Available regions (empty string = auto-detect)
position'bottom-left' | 'bottom-right''bottom-right'Corner where the overlay is positioned

Custom Locales and Environments

Pass custom values for your project:

<DevModeOverlay
  locales={['en', 'de', 'fr', 'es']}
  environments={['production', 'staging', 'development', 'preview']}
  regions={['', 'americas', 'europe', 'asia']}
  position="bottom-left"
/>

Reading Overrides in Server Components

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 NamePurposeExample Value
cms_preview_dateTime travel -- ISO 8601 date sent as preview_date query param2025-12-25T00:00:00.000Z
cms_regionOverride geo-detection -- sent as region query parameurope
cms_environmentContent environment -- sent as environment query paramstaging
localeLanguage -- used by getLocale()de

Conditional Rendering

To only show the overlay in non-production environments, conditionally render it based on an environment variable:

app/layout.tsx
import { DevModeOverlay } from '@/components/DevModeOverlay';

const showDevOverlay = process.env.NEXT_PUBLIC_DEV_OVERLAY === 'true'
  || process.env.NODE_ENV === 'development';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        {showDevOverlay && <DevModeOverlay />}
      </body>
    </html>
  );
}

How It Works

When you change a value in the overlay panel:

  1. A Next.js server action writes the value to a cookie
  2. router.refresh() is called, triggering a server-side re-render
  3. Server Components read the cookies via getDevOverrides()
  4. The override values are passed to fetchContentList() as query parameters
  5. The backend returns content matching the new parameters

The overlay state (open/collapsed) is persisted in localStorage, and override cookies expire after 7 days.

Real-Time Updates

Subscribe to content changes using Server-Sent Events.

SSE Client

'use client';

import { useEffect } from 'react';

// Get the JWT token from your auth state (e.g. localStorage, auth context)
const token = localStorage.getItem('auth_token');

function useContentUpdates(onUpdate: (event: any) => void) {
  useEffect(() => {
    const token = localStorage.getItem('auth_token');
    if (!token) return;

    const eventSource = new EventSource(`/api/content/events?token=${token}`);

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);
      onUpdate(data);
    };

    eventSource.onerror = () => {
      console.error('SSE connection error');
      eventSource.close();
    };

    return () => {
      eventSource.close();
    };
  }, [onUpdate]);
}

function LiveContent() {
  const [content, setContent] = useState([]);

  useContentUpdates((event) => {
    if (event.type === 'updated') {
      // Refetch content or update state
      fetchContent();
    }
  });

  return <div>{/* Render content */}</div>;
}

With SWR Revalidation

'use client';

import useSWR from 'swr';
import { useEffect } from 'react';

function LiveBlogList() {
  const { data, mutate } = useSWR('/api/delivery/blog-post?org=default', fetcher);

  useEffect(() => {
    const token = localStorage.getItem('auth_token');
    if (!token) return;

    const eventSource = new EventSource(`/api/content/events?token=${token}`);

    eventSource.onmessage = () => {
      // Revalidate on any content change
      mutate();
    };

    return () => eventSource.close();
  }, [mutate]);

  return (
    <div>
      {data?.items.map(post => (
        <article key={post.id}>{post.fields.title}</article>
      ))}
    </div>
  );
}

Error Handling

Graceful Fallbacks

async function SafeContent() {
  try {
    const { items } = await fetchContentList<Feature>('feature');
    return <FeatureGrid features={items.map(f => f.fields)} />;
  } catch (error) {
    console.error('Failed to fetch features:', error);
    // Return fallback content
    return <FeatureGrid features={fallbackFeatures} />;
  }
}

Loading States

'use client';

import { Suspense } from 'react';

function BlogPage() {
  return (
    <Suspense fallback={<BlogSkeleton />}>
      <BlogList />
    </Suspense>
  );
}

function BlogSkeleton() {
  return (
    <div className="space-y-4">
      {[1, 2, 3].map(i => (
        <div key={i} className="animate-pulse">
          <div className="h-6 bg-gray-200 rounded w-3/4" />
          <div className="h-4 bg-gray-200 rounded w-full mt-2" />
        </div>
      ))}
    </div>
  );
}

Content Validation

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" }
  ]
}

TypeScript Types

interface FieldError {
  field: string;   // field slug
  message: string; // human-readable error message
}

interface ValidationErrorResponse {
  error: string;          // always "Validation failed"
  field_errors: FieldError[];
}

Handling Validation Errors in React

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

RuleApplies toDescription
requiredAll field typesField must be present and non-empty
min_lengthshort_text, long_text, rich_textMinimum character length
max_lengthshort_text, long_text, rich_textMaximum character length
mininteger, decimalMinimum numeric value
maxinteger, decimalMaximum numeric value
regexshort_text, long_textValue 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:

{
  "image_url_responsive": {
    "default": "/uploads/2025/02/hero_desktop.jpg",
    "tablet": "/uploads/2025/02/hero_tablet.jpg",
    "mobile": "/uploads/2025/02/hero_mobile.jpg"
  }
}

TypeScript Types

types/responsive-media.ts
/** 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.

components/ResponsiveImage.tsx
import type { CSSProperties } from 'react';

interface ResponsiveMedia {
  default: string;
  tablet?: string;
  mobile?: string;
}

interface ResponsiveImageProps {
  media: ResponsiveMedia;
  alt: string;
  style?: CSSProperties;
  className?: string;
  baseUrl?: string; // e.g. process.env.NEXT_PUBLIC_API_URL
}

export function ResponsiveImage({
  media, alt, style, className, baseUrl = ''
}: ResponsiveImageProps) {
  const src = (url: string) => `${baseUrl}${url}`;

  return (
    <picture>
      {media.mobile && (
        <source media="(max-width: 639px)" srcSet={src(media.mobile)} />
      )}
      {media.tablet && (
        <source media="(max-width: 1023px)" srcSet={src(media.tablet)} />
      )}
      <img src={src(media.default)} alt={alt} style={style} className={className} />
    </picture>
  );
}

Usage in a Server Component

Fetch content that contains a responsive media field and render it with the ResponsiveImage component:

app/page.tsx
import { fetchHeroSection, resolveResponsiveMedia } from '@/lib/cms';
import { ResponsiveImage } from '@/components/ResponsiveImage';

const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';

export default async function HomePage() {
  const hero = await fetchHeroSection('home', { locale: 'en' });
  if (!hero) return null;

  const { fields } = hero;
  // Prefer responsive field, fall back to legacy single URL
  const heroMedia = resolveResponsiveMedia(
    fields.image_url_responsive,
    fields.image_url,
  );

  return (
    <section style={{ position: 'relative' }}>
      {heroMedia && (
        <ResponsiveImage
          media={heroMedia}
          baseUrl={apiUrl}
          alt="Hero background"
          style={{
            width: '100%',
            height: '100%',
            objectFit: 'cover',
          }}
        />
      )}
      <h1>{fields.title}</h1>
      <p>{fields.description}</p>
    </section>
  );
}

Using in a Gallery

For gallery items or feature cards where each item may or may not have responsive variants:

import { fetchGalleryItems, resolveResponsiveMedia } from '@/lib/cms';
import { ResponsiveImage } from '@/components/ResponsiveImage';

const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';

export default async function Gallery() {
  const entries = await fetchGalleryItems({ locale: 'en' });
  const images = entries
    .map(e => e.fields)
    .filter(f => f.media_type === 'image');

  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
      {images.map((item, i) => {
        const media = resolveResponsiveMedia(
          item.media_url_responsive,
          item.media_url,
        );
        if (!media) return null;

        return (
          <ResponsiveImage
            key={i}
            media={media}
            baseUrl={apiUrl}
            alt={item.alt_text || item.title}
            style={{ width: '100%', height: '200px', objectFit: 'cover', borderRadius: '8px' }}
          />
        );
      })}
    </div>
  );
}

Validation Rules

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:

{
  "fields": [
    {
      "name": "Image URL",
      "slug": "image_url",
      "type": "short_text",
      "required": false,
      "settings": { "help_text": "Legacy single image URL" }
    },
    {
      "name": "Image URL (Responsive)",
      "slug": "image_url_responsive",
      "type": "responsive_media",
      "required": false,
      "settings": { "help_text": "Responsive image with desktop, tablet, and mobile variants" }
    }
  ]
}

SEO Features

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:

{
  "id": "abc123",
  "fields": { "title": "My Article", "body": "..." },
  "structured_data": {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": "My Article",
    "datePublished": "2024-01-15T10:30:00Z",
    "url": "https://example.com/articles/my-article"
  }
}

Hreflang Alternates

For multi-language entries, fetch with ?include_alternates=true to get alternate locale versions:

{
  "id": "abc123",
  "locale": "en",
  "alternates": [
    { "locale": "de", "slug": "meine-seite", "url": "/de/pages/meine-seite" },
    { "locale": "en", "slug": "my-page", "url": "/en/pages/my-page" }
  ]
}

Content Type SEO Settings

Content types include SEO configuration that controls sitemap inclusion, URL patterns, and structured data generation:

GET /api/delivery/content-types?org={orgSlug}
{
  "content_types": [
    {
      "slug": "pages",
      "seo_settings": {
        "include_in_sitemap": true,
        "default_sitemap_priority": 0.7,
        "default_change_frequency": "weekly",
        "url_pattern": "/pages/{slug}",
        "structured_data_type": "WebPage"
      }
    }
  ]
}

Using SEO in Next.js generateMetadata

Use entry SEO fields to generate rich metadata in Next.js pages:

app/pages/[slug]/page.tsx
export async function generateMetadata({ params }) {
  const entry = await getPageEntry(params.slug);
  const seo = entry?.seo;

  return {
    title: seo?.meta_title || entry?.fields.title,
    description: seo?.meta_description || entry?.fields.description,
    alternates: {
      canonical: seo?.canonical_url || `/pages/${params.slug}`,
      languages: Object.fromEntries(
        (entry?.alternates || []).map(a => [a.locale, a.url])
      ),
    },
    openGraph: {
      title: seo?.og_title || seo?.meta_title,
      description: seo?.og_description || seo?.meta_description,
      images: seo?.og_image ? [{ url: seo.og_image }] : undefined,
    },
    twitter: {
      title: seo?.twitter_title || seo?.og_title,
      description: seo?.twitter_description || seo?.og_description,
      images: seo?.twitter_image ? [seo.twitter_image] : undefined,
    },
    robots: { index: !seo?.no_index, follow: !seo?.no_follow },
  };
}

SEO Integration Components

The integration package exports three SEO components for use in external frontends:

import { CMSSEOHead, CMSJsonLd, CMSHreflangLinks } from '@cms/react';

function PageHead({ entry }) {
  return (
    <>
      {/* Renders <title>, <meta description>, <link canonical>, OG and Twitter tags */}
      <CMSSEOHead
        seo={entry.seo}
        title={entry.fields.title}
        description={entry.fields.description}
        canonicalUrl={`/pages/${entry.fields.slug}`}
      />

      {/* Renders <script type="application/ld+json"> */}
      <CMSJsonLd data={entry.structured_data} />

      {/* Renders <link rel="alternate" hreflang="..."> for each locale */}
      <CMSHreflangLinks
        alternates={entry.alternates}
        baseUrl="https://example.com"
        defaultLocale="en"
      />
    </>
  );
}

Redirects

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.

Redirect API

MethodEndpointAuthDescription
GET/api/delivery/redirects?org=defaultPublicList all redirects
POST/api/redirectsAdminCreate redirect
GET/api/redirectsAdminList/search redirects
PUT/api/redirects/{id}AdminUpdate redirect
DELETE/api/redirects/{id}AdminDelete redirect
POST/api/redirects/importAdminBulk CSV import

Redirect Object

{
  "id": "abc123",
  "source_path": "/old-page",
  "target_path": "/new-page",
  "status_code": 301,
  "is_regex": false,
  "notes": "Page renamed",
  "auto_created": false,
  "created_at": "2024-01-15T10:30:00Z"
}

Auto-Redirects on Slug 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.

CSV Import Format

source_path,target_path,status_code
/old-page,/new-page,301
/legacy-path,/modern-path,302

Using Redirects in Next.js Middleware

middleware.ts
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.

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://example.com/pages/about</loc>
    <lastmod>2024-01-20T14:45:00Z</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.7</priority>
  </url>
</urlset>

Next.js Frontend Sitemap

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

  1. Use TypeScript -- Define interfaces for your content types for type safety
  2. Cache Appropriately -- Use revalidate for ISR in Next.js
  3. Handle Errors -- Always provide fallback content for critical sections
  4. Sort by Position -- Use position fields for ordered content
  5. Filter on Server -- Use locale and other filters in the API call, not client-side
  6. Minimize Requests -- Batch related content fetches with Promise.all
  7. Use SSR for SEO -- Fetch marketing content server-side for search engines
  8. Preview Before Publish -- Use time travel to verify scheduled content
  9. Optimize Media -- Use responsive_media fields to serve smaller images on mobile

Support

For issues or questions:

  • Check the API health: GET /api/health
  • Review server logs for errors
  • Verify organization slug is correct
  • Ensure content is published (not draft)