# 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 (

{heading}

{left}{right}
) } ``` 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 | ---