# 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. Point the SDK at the API
```sh
# .env.local
NEXT_PUBLIC_CONTENTBIND_API_URL=https://api.contentbind.com
NEXT_PUBLIC_CONTENTBIND_KEY=pk_live_...
```
Both come from your workspace. The API URL rarely changes; left unset, the SDK
talks to `https://api.contentbind.com`.
## 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 } 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 url = '/' + (slug ?? []).join('/')
const entry = await fetchOneEntry({ model: 'page', publicKey: KEY, url })
if (!entry) 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.
## 4. 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.
## 5. 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
The architecture, 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.
## 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,
url,
preview: isPreviewing(jar),
previewToken: getPreviewToken(jar),
})
```
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,
url,
variants: { hero_headline_test: assignedVariant },
})
```
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
Server-side targeting on attributes you declare.
## Attributes
Personalization runs on attributes your app already knows: plan, region,
account age, whatever you pass. Declare them once in workspace settings, with
example values, and they become pickable in the editor's rule builder rather
than free-form strings authors have to spell correctly.
## Rules
A block can carry a rule: show when `plan` is `pro`, hide when `region` is
`EU`. Rules are evaluated at delivery, server-side, before the HTML is cached.
The visitor never receives content meant for someone else, and there is no
client-side flicker.
## Previewing
The editor's **Preview as a visitor** control sets attributes for the canvas.
With none set, personalized blocks stay visible but dimmed and labelled, so
authoring a page does not mean hunting for blocks that vanished. With
attributes set, the canvas shows exactly what that visitor gets.
## Passing attributes
```tsx
const entry = await fetchOneEntry({
model: 'page',
publicKey: KEY,
url,
attributes: { plan: user.plan, region: geo.country },
})
```
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 |
---