Multi-tenant Headless Blog CMS.

REST API + per-site API keys + tenant-scoped admin panel.

Tenant-scoped admin access

Client users only work with sites, posts, and media inside their own tenant scope.

Per-site API keys

Each site gets its own key for secure server-to-server integration with a Next.js app.

Public read endpoints

Frontends query published content through dedicated public endpoints protected by `x-site-key`.

Integration (Next.js)

  1. Set environment variables
  2. Fetch posts list in a Server Component
  3. Fetch a post by slug
  4. Test quickly with curl
Environment.env
CMS_URL=https://your-payload-domain.com
SITE_API_KEY=your_site_key
List Posts (Server Component)ts
const res = await fetch(`${process.env.CMS_URL}/api/public/posts`, {
  headers: { 'x-site-key': process.env.SITE_API_KEY! },
  cache: 'no-store',
})

if (!res.ok) throw new Error(`CMS request failed: ${res.status}`)
const { docs } = await res.json()
Fetch by Slugts
const res = await fetch(`${process.env.CMS_URL}/api/public/posts/${slug}`, {
  headers: { 'x-site-key': process.env.SITE_API_KEY! },
  cache: 'no-store',
})

if (!res.ok) throw new Error(`CMS request failed: ${res.status}`)
const post = await res.json()
curl testbash
curl -i "$CMS_URL/api/public/posts" -H "x-site-key: $SITE_API_KEY"

Types and Response Shapes

Use these TypeScript types as a practical baseline for frontend integration.

Public API Typests
type RichTextNode = {
  type: string
  version: number
  [key: string]: unknown
}

type MediaRef = {
  id: number | string
  url?: string | null
  alt?: string
}

type SiteRef = {
  id: number | string
  name?: string
  domain?: string
}

export type PublicPost = {
  id: number | string
  title: string
  slug: string
  excerpt?: string | null
  content: {
    root: RichTextNode
  }
  publishedAt: string
  site: number | string | SiteRef
  cover?: number | string | MediaRef | null
  createdAt?: string
  updatedAt?: string
}

export type PublicPostsResponse = {
  docs: PublicPost[]
  totalDocs: number
}

export type PublicAPIError = {
  errors?: Array<{ message?: string }>
  message?: string
}

Reusable API Client

A typed server-side helper keeps all key/header/error handling in one place.

lib/cms.tsts
const CMS_URL = process.env.CMS_URL!
const SITE_API_KEY = process.env.SITE_API_KEY!

const baseHeaders = {
  'x-site-key': SITE_API_KEY,
}

export async function fetchPublicPosts(): Promise<PublicPostsResponse> {
  const res = await fetch(`${CMS_URL}/api/public/posts`, {
    headers: baseHeaders,
    cache: 'no-store',
  })

  if (!res.ok) {
    const err = (await res.json().catch(() => ({}))) as PublicAPIError
    throw new Error(err.message || `CMS request failed: ${res.status}`)
  }

  return (await res.json()) as PublicPostsResponse
}

export async function fetchPublicPostBySlug(slug: string): Promise<PublicPost> {
  const res = await fetch(`${CMS_URL}/api/public/posts/${slug}`, {
    headers: baseHeaders,
    cache: 'no-store',
  })

  if (res.status === 404) return Promise.reject(new Error('Post not found'))
  if (!res.ok) {
    const err = (await res.json().catch(() => ({}))) as PublicAPIError
    throw new Error(err.message || `CMS request failed: ${res.status}`)
  }

  return (await res.json()) as PublicPost
}
Status and Behaviortxt
// /api/public/posts and /api/public/posts/:slug
// 200 -> success
// 400 -> malformed request (ex: missing slug param in route internals)
// 401 -> missing/invalid x-site-key
// 403 -> site is disabled
// 404 -> slug not found for this site or unpublished

// Only posts with publishedAt <= now are returned.

Implementation Notes

  • Call CMS endpoints from Server Components, route handlers, or server actions only.
  • Do not expose `SITE_API_KEY` in browser bundles.
  • Posts are returned only for the key-resolved site and only if already published.
  • Slug lookup is scoped by site key, so same slug can exist on a different site safely.
Keep API keys server-side only (never expose in browser).