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.

4 min read

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 init

init 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-react

Put 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=production

2. 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.

src/lib/content.ts
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

src/app/[[...slug]]/page.tsx
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.

src/components/Page.tsx
'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

src/app/layout.tsx
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

next.config.ts
const nextConfig = {
	async headers() {
		return [
			{
				source: '/:path*',
				headers: [
					{
						key: 'Content-Security-Policy',
						value: "frame-ancestors 'self' https://content.obelism.studio",
					},
				],
			},
		]
	},
}

export default nextConfig

Then set the environment's preview URL (Settings) to where the site runs, such as http://localhost:3000, and open the Canvas.

Next steps

On this page

Marks in the margin

  • Note
  • Watch out
  • Updated

Scribes marked doubtful lines in the margin. That practice is called obelism.

Stuck on a step?