Skip to content

Personalize content per visitor

Editors can make content vary by visitor. Your site tells the API about the visitor with a context bag, and the API picks what that visitor sees.

Mechanism What varies How an editor sets it up
Whole-field variants A field’s entire value Turns on Personalizable for the field, then adds variants per audience
Personalized passages One passage inside a rich text field Inserts a Personalized embed in the text
Tokens One word or phrase inside a fixed sentence Turns on Allow token injection for the field and writes {{ctx.firstName}} in the text

An audience is a named, reusable condition an editor defines in Content, such as “returning customers” or “on the enterprise plan”. Each variant names the audiences it is for, and the first variant whose conditions match wins; otherwise the field’s default applies. Audiences are evaluated when a request is served, so an editor who changes an audience’s condition changes what visitors see without republishing anything.

The bag is a flat JSON object, sent URL-encoded in the ctx query parameter:

GET /content/delivery/v1/path/pricing?site=<rootNodeId>&ctx=%7B%22audiences%22%3A%5B%22returning%22%5D%2C%22plan%22%3A%22pro%22%7D

which decodes to:

{ "audiences": ["returning"], "plan": "pro" }
  • audiences is a list of strings your site computes about the visitor. Audience conditions usually test it.
  • Any other property is yours to define, and holds a string, a number, a boolean, null, or a list of those. Nested objects are not allowed.
  • locale is reserved. The API always sets it from the request’s own locale.
  • The whole bag is limited to 8 KB. Invalid JSON, a value that isn’t an object, a nested value or an oversized bag answers 400 invalid_context.

ctx is accepted by GET /path, GET /components/{provider}/{key}, GET /components with include=content, and GET /streams/{externalId}. Stream facets take none, because a bag changes what content says, never which items match.

Whether you send ctx at all, not what it contains, decides who picks the variants.

ctx sent (server-resolve) ctx omitted (client-resolve)
Personalized fields Already replaced by the winning value Delivered as { default, variants } for you to evaluate
Tokens Filled in from the bag Left as written
What the visitor’s browser can see Only their own variant Every variant, and the condition that selects it
Responses One per distinct bag One for every visitor

Server-resolve is what the SDK does. createContentClient always sends a bag, {} by default, so every page, reference, stream and navigation read it makes arrives already resolved. Set the bag with the context option, as a value or as a function called before each read, or pass context with a single call:

personalization.tsx
// Personalization with the high-level client. It sends a context bag on every read, so the API
// picks each personalized field's winning variant and fills in tokens before the response leaves
// the server.
import { createContentClient, createDeliveryClient, isVariantSetEnvelope, type ComponentValue, type ContextBag } from '@ebitex/content-sdk'
import { useResolvedComponent } from '@ebitex/content-sdk/react'
declare const DELIVERY_KEY: string
declare function isReturningVisitor(): boolean
declare function currentPlan(): string | null
// In a browser, a function runs before each read, so the bag describes the visitor at that moment.
// Keep it to the properties your audiences and tokens actually read: every distinct bag is its own
// cache entry, in this client and in the API.
export const content = createContentClient({
apiKey: DELIVERY_KEY,
context: (): ContextBag => ({
audiences: isReturningVisitor() ? ['returning'] : [],
plan: currentPlan(),
}),
})
// On a server, one client serves every visitor, so pass each request's bag with the call.
export function resolveForVisitor(path: string, bag: ContextBag) {
return content.resolveLocation(path, { context: bag })
}
// When something changes the visitor's context, re-read one part of the page with the new bag.
// The rest of the page stays as it is.
export function PlanBanner({ value, plan }: { value: ComponentValue<{ headline: string }>; plan: string }) {
const state = useResolvedComponent(value)
if (state.status !== 'ready') return null
return (
<aside>
<p>{state.value.content.headline}</p>
<button onClick={() => state.refresh({ plan })}>Show offers for my plan</button>
</aside>
)
}
// Client-resolve: the low-level client sends `ctx` only when you pass it. Without it, a
// personalized field arrives as `{ default, variants }` with every variant's conditions, and
// choosing is up to you. Use this only for content whose every variant anyone may see.
const delivery = createDeliveryClient({ apiKey: DELIVERY_KEY })
// Your own implementation of the condition rules described in the personalization guide.
declare function conditionsMatch(conditions: unknown[], bag: ContextBag): boolean
export async function headlineFor(key: string, bag: ContextBag): Promise<unknown> {
const promo = await delivery.getComponent('core', key)
const headline = promo.content.headline
if (!isVariantSetEnvelope(headline)) return headline
// The first variant whose conditions all match wins. When none does, the default applies.
const variants = headline.variants as Array<{ conditions: unknown[]; value: unknown }>
const winner = variants.find((variant) => conditionsMatch(variant.conditions, bag))
return winner ? winner.value : headline.default
}

On a server, one client serves every visitor, so a context function on the client cannot know whose request it is. Pass each request’s bag with the call, as resolveForVisitor does.

After something changes the visitor’s context, such as signing in, refresh(context) from useResolvedComponent re-reads just that component with the new bag and leaves the rest of the page alone.

Client-resolve is a low-level choice. The low-level client sends ctx only when you pass it. Use it only for content whose every variant anyone may read, since the response contains them all. The SDK’s renderers do not evaluate an unresolved envelope; you do, as headlineFor shows.

A personalized field in a client-resolve response looks like this:

{
"variants": [
{
"conditions": [
{ "predicate": { "kind": "in", "property": "audiences", "values": ["returning"] } },
{ "predicate": { "kind": "compare", "property": "visits", "op": "gte", "value": 3 } }
],
"value": "Welcome back"
}
],
"default": "Welcome"
}

A variant matches when all of its conditions match, and the first matching variant wins. A condition an editor wrote against an audience arrives already expanded into that audience’s predicate; audience ids never appear in a response. A rich text field’s personalized passage arrives the same way, as a personalized fragment whose default and each variant’s value are fragment arrays.

kind Members True when
equals property, value The property equals value. For a list property, any element does.
notEquals property, value equals would be false, including when the property is missing.
in property, values The property equals any member of values. For a list property, any element does.
compare property, op, value Both are numbers and op (lt, lte, gt or gte) holds.
exists property The property is present and not null.
notExists property The property is missing or null.
allOf predicates Every member is true.
anyOf predicates At least one member is true.

Types are never converted: the string "3" does not equal the number 3, and compare is false for anything that isn’t a number. A property that is missing or null makes every kind false except notEquals and notExists.

A token is {{ctx.<property>}} or {{ctx.<property> | fallback text}}, inside a field an editor allowed tokens on. With server-resolve, the property’s value replaces it: a string, number or boolean as text, and the fallback (or nothing) when the property is missing or holds a list. Tokens are only filled in when you send ctx and don’t ask for raw=true.

Every distinct bag is a distinct response. That is true of the API’s own cache, the SDK’s in-memory cache and any cache you put in front of your site, and it is what keeps one visitor from ever being served another’s content. It also means:

  • Keep the bag coarse. Send the few properties your audiences and tokens read. A bag carrying a visitor id or a timestamp makes every request unique, and nothing can be reused.
  • A per-visitor token costs a per-visitor response. A token such as {{ctx.firstName}} needs the name in the bag, so that page can no longer be shared between visitors.
  • If you cache delivery responses yourself, key on the full URL, ctx included. Never strip it.
  • Client-resolve trades privacy for reuse. One response serves everyone, and everyone can read every variant.

See Caching for the rest of the picture. To check what a visitor will see before publishing, editors can use Preview in Content and simulate audiences and properties.