Tenant-scoped admin access
Client users only work with sites, posts, and media inside their own tenant scope.
Client users only work with sites, posts, and media inside their own tenant scope.
Each site gets its own key for secure server-to-server integration with a Next.js app.
Frontends query published content through dedicated public endpoints protected by `x-site-key`.
CMS_URL=https://your-payload-domain.com
SITE_API_KEY=your_site_keyconst 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()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 -i "$CMS_URL/api/public/posts" -H "x-site-key: $SITE_API_KEY"Use these TypeScript types as a practical baseline for frontend integration.
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
}A typed server-side helper keeps all key/header/error handling in one place.
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
}// /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.