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.
What editors can personalize
Section titled “What editors can personalize”| 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 context bag
Section titled “The context bag”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%7Dwhich decodes to:
{ "audiences": ["returning"], "plan": "pro" }audiencesis 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. localeis 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.
Server-resolve or client-resolve
Section titled “Server-resolve or client-resolve”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 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: stringdeclare function isReturningVisitor(): booleandeclare 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.
Evaluating variants yourself
Section titled “Evaluating variants yourself”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.
Tokens
Section titled “Tokens”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.
What personalization does to caching
Section titled “What personalization does to caching”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,
ctxincluded. 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.