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.

Personalization changes what a page says. It is not access control: with client-resolve every variant reaches the browser. To keep a page from visitors who are not members, see Serve pages only to members.

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 group of visitors, such as “returning customers” or “on the enterprise plan”. Audiences are defined in the Audience app, which owns the rule that decides who is in one. Each variant names the audiences it is for, by id, and the first variant whose conditions match wins; otherwise the field’s default applies. Content stores no rule and never calls Audience: Audience says which audiences a visitor is in, as a signed token that Content verifies; the SDK’s audience option fetches, keeps, renews and sends it for you. Because variants are chosen when a request is served, changing who is in an audience changes what visitors see without republishing anything. See Audiences for defining them and resolving a visitor.

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%22plan%22%3A%22pro%22%7D
X-Ebitex-Audiences: a1.…

which decodes to:

{ "plan": "pro" }
  • Audiences are not part of the bag. A variant that names an audience matches when the visitor holds that audience in the signed token in the X-Ebitex-Audiences header, which POST /audience/delivery/v1/resolve returns. A request that puts audiences in ctx is refused with 400 audiences_not_in_ctx.
  • A variant on a stored-membership audience is access control; a variant on an audience worked out from context is public copy. Content withholds the first from anyone who does not hold the audience, even under client-resolve, and leaves it out of search text. The second is something a caller can ask for by sending the right context, so never put anything private in 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 const AUDIENCE_DELIVERY_KEY: string
declare function currentPlan(): string | null
// A personalized variant can name an audience. Which audiences the visitor is in is Audience's to say, and Content takes it only as a signed token: with the `audience` option the client
// asks Audience, keeps the token, renews it and sends it with every read. You never put an audience in the context.
export function contextFor(plan: string | null): ContextBag {
return { plan }
}
// 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,
audience: { apiKey: AUDIENCE_DELIVERY_KEY },
context: (): ContextBag => contextFor(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": [
{ "audience": "5b8e4f2a-0c1d-4e7b-9a3f-2d6c8e1b7a40" },
{ "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. An audience condition matches when that id is in the list of audience ids you resolved for the visitor; a predicate condition is evaluated against your own properties, as the table below describes. 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 audience ids and the few properties your tokens and conditions 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.