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.
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 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 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%22plan%22%3A%22pro%22%7DX-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-Audiencesheader, whichPOST /audience/delivery/v1/resolvereturns. A request that putsaudiencesinctxis refused with400 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. 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 const AUDIENCE_DELIVERY_KEY: stringdeclare 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.
Evaluating variants yourself
Section titled “Evaluating variants yourself”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.
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 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,
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.