Getting started
Next.js
Start a Next.js (App Router) site on a space with npx @obelism/content init, render its pages by path, and turn on live preview in the canvas.
This sets up a Next.js App Router site that serves every page entry at its path, shows drafts in the canvas, and lets editors sign in on the site to change it. The example in the SDK repository is the finished version.
1. Start the repository on a space
Create a management token under Settings → API tokens, then run in the site's root:
export CONTENT_MANAGEMENT_TOKEN=cms_m_…
npx @obelism/content initinit writes the model file (content/content-model.json), an example of what the delivery API answers for every content type (content/examples/), installs @obelism/content, and adds the token variables to .env.example. Add @obelism/content-react for the React helpers:
npm i @obelism/content-reactPut a delivery token in .env.local (never commit it):
CONTENT_DELIVERY_TOKEN=cms_d_…
# The space and environment it reads (Space settings), so the canvas's
# token for another space reads nothing here.
CONTENT_SPACE_ID=space_…
CONTENT_ENVIRONMENT=production2. The clients
One client for published content, cached by Next. Drafts come with a token for the person editing: in the canvas the page's URL carries cms_token, and an editor signed in on the site has a site token while draft mode is on. Those requests get a client of their own, never cached. Previews and draft mode explains the order and why.
import 'server-only'
import { ContentClient, readPreviewParams } from '@obelism/content'
import { readSiteToken } from '@obelism/content/site'
import { cookies, draftMode } from 'next/headers'
export const delivery = new ContentClient({
token: process.env.CONTENT_DELIVERY_TOKEN!,
init: { next: { revalidate: 60, tags: ['content'] } },
})
// Naming the space and environment makes a token someone made for another
// space read nothing here.
const drafts = (token: string) =>
new ContentClient({
token,
space: process.env.CONTENT_SPACE_ID!,
environment: process.env.CONTENT_ENVIRONMENT!,
init: { cache: 'no-store' },
})
// `cms_preview` alone proves nothing: anyone can add it to a URL.
export const getContent = async (
searchParams: Record<string, string | string[] | undefined>,
) => {
const preview = readPreviewParams(searchParams)
if (preview?.token) return drafts(preview.token)
if ((await draftMode()).isEnabled) {
const token = readSiteToken(await cookies())
if (token) return drafts(token)
}
return delivery
}3. Every page by path
import { ContentApiError } from '@obelism/content'
import { notFound } from 'next/navigation'
import { Page, type PageEntry } from '@/components/Page'
import { delivery, getContent } from '@/lib/content'
type Props = {
params: Promise<{ slug?: string[] }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function CatchAll({ params, searchParams }: Props) {
const { slug } = await params
const path = slug?.join('/') || 'home'
const content = await getContent(await searchParams)
const entry = await content
.getPage<PageEntry>(path, { depth: 2 })
.catch((error: unknown) => {
// A made-up, expired or other space's `cms_token`: show what's published.
if (error instanceof ContentApiError && error.status === 401) {
// Says why, when the canvas shows published content (guides/canvas).
console.warn('Content refused the draft token:', error.body)
return delivery.getPage<PageEntry>(path, { depth: 2 })
}
throw error
})
if (!entry) notFound()
return <Page entry={entry} />
}4. Render the entry
useLiveEntry returns the editor's unsaved version while the page is open in the canvas, and entry everywhere else. editable marks the entry and its blocks, so editors can click them.
'use client'
import {
type Block,
editable,
type Entry,
RichText,
type RichTextNode,
useLiveEntry,
} from '@obelism/content-react'
type HeroBlock = Block<{ heading: string | null }, 'hero'>
type TextBlock = Block<{ text: RichTextNode | null }, 'text'>
export type PageEntry = Entry<
{ title: string | null; body: (HeroBlock | TextBlock)[] | null },
'page'
>
export const Page = ({ entry }: { entry: PageEntry }) => {
const { fields } = useLiveEntry(entry)
return (
<main {...editable(entry)}>
<h1>{fields.title}</h1>
{fields.body?.map((block) =>
block._type === 'hero' ? (
<section key={block._uid} {...editable(block)}>
<h2>{block.heading}</h2>
</section>
) : (
<section key={block._uid} {...editable(block)}>
<RichText document={block.text} />
</section>
),
)}
</main>
)
}The types in content/examples/ show what each of your content types looks like; adjust PageEntry to your own model.
5. Start the bridge
import { ContentBridge } from '@obelism/content-react'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<ContentBridge />
{children}
</body>
</html>
)
}It does nothing outside the editor.
6. Let the CMS frame the site
const nextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://content.obelism.studio",
},
],
},
]
},
}
export default nextConfigThen set the environment's preview URL (Settings) to where the site runs, such as http://localhost:3000, and open the Canvas.
Next steps
- Revalidate on publish: call
revalidateTag('content')from a webhook route. - Editing on the site: one route handler, and editors sign in on the site itself.
- Images: resized and re-encoded copies with a query on the URL.
- A/B tests in content, with Obelism Improve.