# ContentBind documentation
Generated from https://contentbind.com/docs. Each section below is one page.
# Quickstart
From an empty project to an editable page in about ten minutes.
## Before you start
You need a workspace and its public key. ContentBind is invite only, so the
workspace is created for you; the key is in **Settings → Environments**, one per
environment, and it is safe to ship in client code. It grants read access to
published content and nothing else.
## 1. Install
```sh
npm install @contentbind/sdk
```
## 2. Add your public key
```sh
# .env.local
NEXT_PUBLIC_CONTENTBIND_KEY=pk_live_...
```
Find it in your workspace under Settings, per environment. Env-file syntax:
no quotes, no colon, and restart the dev server afterwards - NEXT_PUBLIC_
variables are inlined at build time, so a running server never sees a new one.
## 3. Render a page
Fetch on the server, render on the client. The fetch is a plain HTTP call with
no runtime, so it works in any framework; this example is the Next.js App
Router.
```tsx
// app/[[...slug]]/page.tsx
import { fetchOneEntry, isContentBindError } from '@contentbind/sdk'
import { Content } from '@contentbind/sdk/react'
const KEY = process.env.NEXT_PUBLIC_CONTENTBIND_KEY!
export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
const { slug } = await params
const urlPath = '/' + (slug ?? []).join('/')
const entry = await fetchOneEntry({ model: 'page', publicKey: KEY, urlPath })
if (isContentBindError(entry)) {
console.error(`ContentBind ${entry.kind}: ${entry.message}`)
return null
}
return
}
```
That is a working integration. Every page an author creates at a URL your app
routes to now renders, and the built-in component library is available to them
immediately.
The package has two entry points, split by where the code runs:
`@contentbind/sdk` is server and build-time code with no React in it
(`fetchOneEntry`, `fetchEntries`, `isPreviewing`, `getPreviewToken`,
`isContentBindError`); `@contentbind/sdk/react` is components and hooks
(`Content`, `useIsPreviewing`, `useTrackExperimentAssignment`). The
near-twins differ on purpose: `isPreviewing(cookieJar)` is the server
function, `useIsPreviewing()` is the client hook.
Two things the type system will hold you to:
- **Every fetch can return an error object.** `fetchOneEntry` returns
`Entry | ContentBindError` and `fetchEntries` returns
`Array<...> | ContentBindError` - never a thrown exception, never null.
Always narrow with `isContentBindError(result)` before touching the data;
calling `.map` or reading `.blocks` straight off the result will crash the
moment a key is wrong or the network hiccups.
- **The URL parameter is `urlPath`.** Passing `url` compiles in loosely-typed
code and silently fetches `/` instead.
```tsx
import { fetchEntries, isContentBindError } from '@contentbind/sdk'
const entries = await fetchEntries({ model: 'page', publicKey: KEY })
if (isContentBindError(entries)) {
console.error(`ContentBind ${entries.kind}: ${entries.message}`)
return []
}
return entries.map((e) => e.url)
```
## 4. Titles and descriptions
Authors set a page title and description in the editor (click the page name in
the top bar). They arrive on the entry as `entry.meta.title` and
`entry.meta.description` - the SDK deliberately leaves the document head to
your framework, because search engines need it server-rendered.
App Router:
```tsx
export async function generateMetadata({ params }): Promise {
const entry = await fetchOneEntry({ model: 'page', publicKey: KEY, urlPath })
if (isContentBindError(entry)) return {}
return { title: entry.meta.title, description: entry.meta.description }
}
```
Pages Router, in the page component:
```tsx
import Head from 'next/head'
{entry.meta.title ? {entry.meta.title} : null}
{entry.meta.description ? : null}
```
## 5. Open the editor
In ContentBind, create a page whose URL matches a route in your app, then open
it. The canvas is your site in an iframe, loaded from your own origin, with
`?contentbind.editor=1` appended. Nothing is proxied and nothing is re-hosted:
what an author sees is your application running your code.
If the canvas stays blank, run the doctor:
```sh
npx contentbind doctor
```
It checks the key, the API, the delivery call and the editor origin, and tells
you which of the four is wrong.
## 6. Register your own components
The built-ins get a page shipped. Your components make it yours.
```tsx
import { Content } from '@contentbind/sdk/react'
import { PricingTable } from '@/components/PricingTable'
const COMPONENTS = [
{
component: PricingTable,
name: 'PricingTable',
friendlyName: 'Pricing table',
category: 'Marketing',
inputs: [
{ name: 'heading', type: 'string', defaultValue: 'Plans' },
{ name: 'highlight', type: 'string', options: [
{ label: 'Starter', value: 'starter' },
{ label: 'Growth', value: 'growth' },
] },
],
},
]
```
The component stays in your repository. ContentBind never sees its source, only
its name and the inputs you declared: it sends props, your code renders them.
See [Custom components](/docs/components) for the full input vocabulary.
---
# How it works
What the editor does, and why the canvas is your own site.
## Three pieces
**The platform** is a single Cloudflare Worker. It owns the API, authentication,
publishing and delivery. Content lives in R2, metadata in D1, the read path is
cached in KV, and live presence runs on a Durable Object.
**The editor** is a React SPA. It holds no content of its own: it reads through
the API and drives the canvas over `postMessage`.
**The canvas** is your application. The editor loads your site in an iframe from
your origin with the editor flag set, and the SDK inside it answers: here are my
components, here is where each block rendered, this text was edited.
## Why an iframe and not a renderer
The alternative is for the builder to re-implement your components, which means
a second version of your design system that drifts from the first. Loading your
real site means an author edits the thing that ships. Your CSS, your fonts, your
data fetching, your framework version.
The cost is a boundary: the editor and the canvas are different origins, so they
talk in messages rather than function calls. Every message is origin-checked
against the workspace's registered editor origin, in both directions.
## The read path
```
Visitor → your app → fetchOneEntry() → Worker → KV cache → R2
```
Published content is written once to R2 and cached in KV keyed by environment,
URL, and the resolved variant and personalization combination. The cache key
includes those dimensions, which is why the editor warns when a page's variant
count multiplies past a soft cap: every combination is a separate cached object.
## The write path
Drafts autosave to R2 under a draft key. Publishing copies the draft to the
published key for the chosen environments, writes a version row, and purges the
affected cache keys. A version is a full snapshot, so restoring is a copy rather
than a replay of changes.
---
# Custom components
Registering your own components and declaring their inputs.
## Registration
A registration is your component plus a description of what an author may
change about it.
```tsx
{
component: Hero,
name: 'Hero', // stable id, stored in content
friendlyName: 'Hero', // shown in the palette
category: 'Marketing', // groups it in the palette
icon: 'megaphone',
inputs: [ /* ... */ ],
}
```
`name` is stored in every block that uses the component, so renaming it orphans
existing content. `friendlyName` is free to change.
## Input types
| Type | Editor control | Prop value |
|---|---|---|
| `string` | text field, or a select when `options` is set | `string` |
| `longText` | textarea | `string` |
| `richText` | formatting toolbar | document object |
| `number` | numeric field | `number` |
| `boolean` | switch | `boolean` |
| `color` | colour picker with your brand palette | `string` |
| `file` | media library | URL `string` |
| `list` | repeatable rows of `subFields` | array of objects |
| `blocks` | a drop target for other blocks | rendered children |
```tsx
inputs: [
{ name: 'heading', type: 'string', required: true, defaultValue: 'Ship faster' },
{ name: 'body', type: 'richText' },
{ name: 'image', type: 'file', allowedFileTypes: ['png', 'jpg', 'webp'] },
{ name: 'features', type: 'list', subFields: [
{ name: 'title', type: 'string' },
{ name: 'detail', type: 'longText' },
] },
{ name: 'children', type: 'blocks' },
]
```
## Slots
An input of type `blocks` becomes a slot: authors drop other components inside
yours. Your component receives it as a prop holding rendered children.
```tsx
function Split({ heading, left, right }: {
heading: string
left: ReactNode
right: ReactNode
}) {
return (
)
}
```
An empty slot renders a placeholder in the editor and nothing at all for
visitors, so a container is visible and droppable before it holds anything.
## Brand colours
Hand the SDK your palette and it appears first in every colour picker, so
authors pick from the brand instead of typing a hex from memory.
```tsx
```
## Inline editing
Double-clicking text on the canvas edits it in place. This works with no
integration at all: the clicked element's text is matched back against the
block's own input values, including values inside `list` inputs. When the match
is ambiguous, because two inputs hold the same string or the element holds a
concatenation, editing is declined rather than guessed at, and the author uses
the inspector.
---
# Preview and drafts
Seeing unpublished content, and sharing it with people outside.
## The preview handshake
The editor's **Preview** button opens
`{your site}/api/contentbind-preview?token=...&redirect=...` - a route your
app provides. It stores the short-lived token in a cookie and redirects to the
page. Without it, Preview lands on a 404.
App Router:
```tsx
// app/api/contentbind-preview/route.ts
import { NextResponse, type NextRequest } from 'next/server'
export function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token')
const redirect = request.nextUrl.searchParams.get('redirect') ?? '/'
if (!token) return NextResponse.json({ error: 'token required' }, { status: 400 })
const target = redirect.startsWith('/') ? redirect : '/'
const response = NextResponse.redirect(new URL(target, request.url), 303)
response.cookies.set('contentbind_preview', token, {
httpOnly: true, sameSite: 'lax', maxAge: 900, path: '/',
})
return response
}
```
Pages Router:
```tsx
// pages/api/contentbind-preview.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const token = typeof req.query.token === 'string' ? req.query.token : ''
const redirect = typeof req.query.redirect === 'string' && req.query.redirect.startsWith('/')
? req.query.redirect : '/'
if (!token) return res.status(400).json({ error: 'token required' })
res.setHeader('Set-Cookie',
`contentbind_preview=${encodeURIComponent(token)}; Path=/; Max-Age=900; HttpOnly; SameSite=Lax; Secure`)
res.redirect(303, redirect)
}
```
The page being previewed must read the cookie on the server - statically
generated pages cannot see it. In the Pages Router that means
`getServerSideProps` (or checking `req.cookies.contentbind_preview`); in the
App Router, `cookies()` as below.
## Draft mode
```tsx
import { fetchOneEntry, isPreviewing, getPreviewToken } from '@contentbind/sdk'
import { cookies } from 'next/headers'
const jar = await cookies()
const entry = await fetchOneEntry({
model: 'page',
publicKey: KEY,
urlPath,
options: {
preview: isPreviewing(jar),
previewToken: getPreviewToken(jar),
},
})
if (isContentBindError(entry)) return null
```
With `preview` set, delivery returns the draft and skips the cache entirely.
## Share links
A preview link lets someone read a draft with no account: a designer, a legal
reviewer, a client. Links can carry a password and an expiry, and can be
revoked. The token grants one entry, not a workspace: it cannot be pointed at
another page.
---
# CLI
Scaffolding an integration and diagnosing a broken one.
```sh
npx contentbind init # scaffold the route, env file and a first component
npx contentbind doctor # check an integration end to end
```
## doctor
Six checks, in the order things actually break:
1. The key is present and shaped like a key.
2. The API answers.
3. A real delivery call for a real URL returns content.
4. The editor origin registered for the workspace matches where the editor runs.
5. The SDK version in the project is one the API still speaks to.
6. The canvas responds to the editor handshake.
Each failure names the file or setting to change. A blank canvas is nearly
always one of the first four, and doctor will say which.
---
# Publishing
Environments, versions, approvals and scheduling.
## Environments
A workspace starts with Production only. Add Staging, or a per-team preview
environment, in **Settings → Environments**. Each has its own public key, its
own published content, and its own cache.
Protected environments require an approval before anything reaches them.
## Publishing
Publishing takes the current draft and copies it to the environments you pick.
It is a copy, not a build: the same bytes that were reviewed are the bytes that
go live, and the cache purge is part of the same operation. A page is live in
under a minute, usually a few seconds.
## Versions
Every publish and every manual save (⌘S) writes a version. The history drawer
shows them with who and when. Clicking one *previews* it on the canvas rather
than applying it, and restoring goes through the normal edit path, so ⌘Z takes
it back. Looking at an old version can never cost you your work.
## Approvals
Authors can edit but not publish. Their primary action is **Submit for review**,
which notifies the workspace's approvers. An approver sees the change summary
and either publishes it or sends it back with a comment.
## Scheduling
A publish can be dated. The Worker's cron checks each minute for anything due
and publishes it with the same code path as a manual publish, so a scheduled
release behaves identically to one someone clicks.
---
# Experiments
Running A/B tests with the flag tool you already use.
## ContentBind does not decide who sees what
Your experimentation tool owns the flag, the bucketing and the results.
ContentBind owns the content each bucket sees. That split is deliberate: teams
already have a tool for this and do not want a second source of truth for what
is running.
Supported flag providers: LaunchDarkly, Statsig, GrowthBook, Split, Optimizely,
Eppo, and a built-in bucketer for teams with no external tool.
## Starting one
In the editor, open **Experiments → Start an experiment**. It asks for two
things: the flag key, exactly as it exists in your tool, and the list of
variants. The first variant is the default, which is what anyone unassigned, or
assigned to a variant this page has never heard of, falls back to.
There is no hypothesis field and no sample-size calculator. Those live in the
tool that owns the flag.
## Authoring per variant
With an experiment running, the topbar gets a variant switcher. Switch to
`variant_b`, edit the headline, switch back: the control is unchanged. Any
input can differ per variant, and only the inputs you actually change are
stored, so a variant is a diff rather than a copy of the page.
Blocks can also be shown only for a variant, which is how you test a section
existing at all.
## Serving
Pass the visitor's assigned variants when you fetch:
```tsx
const entry = await fetchOneEntry({
model: 'page',
publicKey: KEY,
urlPath,
options: { variants: { hero_headline_test: assignedVariant } },
})
if (isContentBindError(entry)) return null
```
Delivery resolves the content server-side and caches per combination, so a
visitor gets fully-formed HTML with no flash of the control.
## Ending one
Ending an experiment promotes one variant's content into the page proper and
removes the rest. The promotion is a normal edit, so it lands on the version
history and can be undone.
---
# Personalization
Saying something different to a campaign, an audience, or one arm of an experiment.
## Audiences
An audience is a group of visitors this page can say something different to.
You declare it once for the page, in a block's **Rules** tab, and every block
that changes for it points at the same definition, so "the spring campaign"
means one thing across the page.
An audience can read three kinds of fact:
| Source | Attribute | Comes from |
| --- | --- | --- |
| URL param | `param.utm_campaign` | the visitor's own query string |
| Experiment | `experiment.homepage_hero` | the variant they were assigned |
| Attribute | `plan`, `region` | whatever your app passes as `userAttributes` |
The `param.` and `experiment.` prefixes are reserved, so a rule can never be
confused by an attribute your site happens to name the same thing.
## Editing for an audience
Pick the audience in the top bar and edit normally. Every control in the
Content and Style tabs now writes to that audience: change the headline, swap a
dropdown, set a different background, and each field you touch gets a dot and a
**reset** beside it. Everything you leave alone keeps following the copy
everyone else sees, so there is no second version of the section to maintain.
This is the same gesture as editing mobile styles: switch the canvas to a phone
and the Style tab edits the mobile bucket. Switch to an audience and the whole
panel edits that audience.
Switch back to **Everyone** to edit what everyone sees.
## Experiments are audiences
Starting an experiment creates one audience per variant past the control, named
after the arm. They appear in the same switch under **Experiment arms**, and
picking one also sets the canvas to that bucket. Authoring what an arm says is
therefore the same as authoring what a campaign says, over every field rather
than only the text ones.
If a visitor is in an arm and a campaign at once, both apply, in the page's
audience order, and a later one wins a field an earlier one also sets.
## Where a rule is decided
Audiences defined by your own attributes or by an experiment arm are resolved
at delivery, server-side, before the HTML is cached — and their overrides are
then stripped from the response, so no visitor receives the copy written for
another audience.
An audience keyed on the URL cannot be resolved that way: a page built ahead of
time has no visitor query string, and deciding it early would cache one
campaign's answer for everyone. Those audiences travel with the content and the
SDK applies them in a layout effect, before the first paint. That is what makes
campaign links work on a statically generated or ISR page with no extra wiring.
If your page is rendered per request, hand the query string over and they are
resolved server-side too, with nothing left for the browser to do:
```tsx
// App Router, rendered per request
export default async function Page({ searchParams }) {
const query = await searchParams
const entry = await fetchOneEntry({
model: 'page',
publicKey: KEY,
urlPath: '/landings/formula',
searchParams: query,
userAttributes: { plan: user.plan },
})
if (isContentBindError(entry)) return null
return
}
```
## Previewing
Picking an audience already puts the canvas in that visitor's shoes. **Preview
as a visitor** is for everything else: type an attribute, or a URL parameter
like `utm_campaign` / `formula`, to see the page exactly as delivery would
build it.
With nothing set, the canvas shows the copy everyone gets, and blocks hidden by
a rule stay visible but dimmed and labelled — authoring a page should not mean
hunting for blocks that vanished.
## Passing your own attributes
```tsx
const entry = await fetchOneEntry({
model: 'page',
publicKey: KEY,
urlPath,
userAttributes: { plan: user.plan, region: geo.country },
})
if (isContentBindError(entry)) return null
```
Declare them once in workspace settings, with example values, and they become
pickable in the rule builder rather than free-form strings authors have to
spell correctly. Every distinct combination is a distinct cache key; the editor
tracks the total and warns before it multiplies past the point where the cache
stops helping.
---
# Delivery API
The HTTP surface behind the SDK.
The SDK is a thin wrapper. Anything it does can be done with `fetch`, which is
what non-JavaScript stacks do.
## Fetch one entry
```http
GET /api/delivery/:publicKey/entry?model=page&url=/pricing
```
Query parameters:
| Name | Meaning |
|---|---|
| `model` | content model, usually `page` |
| `url` | the page's URL path |
| `variants` | JSON object of flag key to variant |
| `attributes` | JSON object of personalization attributes |
| `preview` | `1` to read the draft; requires a preview token |
Returns the resolved entry: variant content merged, personalization applied,
hidden blocks removed.
## List entries
```http
GET /api/delivery/:publicKey/entries?model=page&limit=50
```
For sitemaps and index pages. Returns metadata, not full content.
## Redirects
```http
GET /api/delivery/:publicKey/redirect?url=/old-path
```
Returns the target and status code, or 404. Authors manage redirects alongside
pages so a URL change does not need a deploy.
## Errors
Errors come back as `{ error: { code, message } }` with a real status code.
The SDK's fetch helpers return the error object rather than throwing, so a
delivery hiccup renders your fallback instead of a 500.
---
# Security model
Keys, isolation, and what the bridge will and will not do.
## Two kinds of key
**Public keys** ship in client code. They read published content for one
environment. They cannot write, cannot read drafts without a preview token, and
cannot see other environments.
**Sessions** are httpOnly cookies signed with `AUTH_SECRET`. Every write goes
through one, with a role check per workspace.
## Workspace isolation
Content is sharded across pod databases. The shard opener takes the workspace id
and asserts it inside the query, not just in the routing table, so a bug in
routing produces an error rather than another workspace's rows.
## The editor bridge
The editor and the canvas run on different origins and exchange `postMessage`
events. Both sides check `event.origin` against the workspace's registered
editor origin. A page that loads the SDK cannot be driven by an arbitrary
opener.
Content edited inline is sent as values, never as markup, and rich text is
stored as a document rather than HTML: a `javascript:` link is refused at the
editor and never reaches storage.
## Roles
| Role | Can |
|---|---|
| Owner | everything, including billing and deleting the workspace |
| Admin | manage members, environments and settings |
| Editor | edit and publish |
| Author | edit and submit for review |
| Viewer | read |
---