docs
/
AI skills

Show different content to different visitors (variants)

Build Studio variants, the server-side way to swap one element of a page for another per visitor (by campaign link, referrer, URL or time). The stored shape, every condition and what the server really sees for it, how a variant is picked, screen variants, the client-side data-show alternative, and what caching does to it.

A variant is another version of one element on a page: the same <section>, with different content. Build Studio stores the variants beside the page, and the site's server picks one per request from conditions you set, before the page is sent. Visitors who match get that variant; everyone else gets the version that was on the canvas when the page was saved.

The other way to show different things is in the browser, with data-show / data-hide from the page runtime. The two are compared in step 1. For the page record itself, see Build a website page; for the runtime attributes, see The Appmint JS engine on a page.

Most of the conditions Build Studio offers never match on the site today. The server doesn't know the visitor's device, browser, cookies, headers, screen or sign-in, and nothing feeds it a country. What does work: query parameters, the referrer, the URL (with contains/endsWith), and the server's clock. The full list is in step 3. Check it before promising a segment.

Ask first

  • Which page, and which part of it changes? One element per variant set: a hero, a banner, a pricing block. The rest of the page stays the same for everyone.
  • Who sees which version? Say it as a rule: "visitors whose link has ?utm_source=spring", "visitors arriving from Google", "between 6pm and midnight". Then match it to a condition that works (step 3). If the rule is "mobile visitors", "signed-in customers", "VIP group" or "visitors in Canada", say plainly that no variant can do it today and offer the alternative in step 1.
  • What does everyone else see? That is the version left on the page when it is saved.
  • The words and images for each version. Use the person's own. Don't invent offers or prices.
  • Is the page already live? On a site that serves drafts, a save is live at once (see Publish, unpublish and restore a page).

Steps

1. Choose: a server variant or data-show

The ruleUseWhy
A campaign link (?utm_source=spring, ?ref=partner)Server variant (Request › Query Param), or data-show="query.utm_source === 'spring'"Both work. The variant sends only the winning markup; data-show sends every version and hides the others
Arrived from a site (Google, a partner)Server variant (Request › Referrer, contains)The runtime has no referrer key for data-show
Part of the URLServer variant (Request › URL Path, contains or endsWith), or data-show on path.*The server's path is the whole URL (step 3)
Time of day, day of week, a month, a dateServer variant (Time)Server time and time zone, not the visitor's
A Monthly / Annually switch, a tab the visitor clicksdata-show with state.set (step 6), or wm-tabsIt changes after the page loads; a server variant can't
Mobile vs desktopCSS media queries in the page <head>The server always sees "desktop"; the runtime has no device key
Signed in vs signed outNeither, reliably. The customer portal for anything privateThe server never sees the sign-in; customer.signedIn can read true for an anonymous visitor
A customer group, a tag, a past purchaseNot possible on a page todayNo condition for it
A country or cityNot possible todayThe engine checks geo, but nothing fills it in, and Build Studio has no Geo option

Build Studio selects a segmented switch (a wrapper of 2 to 6 buttons) as one block on the first click. That is only how the canvas selects things (composite-select.ts). On the site the buttons do nothing until you give them actions (step 6).

Hands on: the rule, the condition it maps to, and the page. Check: the condition is marked "works" in step 3.

2. Build Studio: read the page and mark the element

Read the whole record: repo_find on page with { "data.site": "<site>", "data.name": "<page>" }, then repo_find_one on page with its id. A find result is partial.

Variants live on the page's rendered screen: the screens[] entry whose id is data.activeScreenId (or the first screen). Find the element in that screen's html. It needs a data-node-id attribute: Build Studio stamps one on every element when the page is opened on the canvas and saves it. A page written through the API may have none, so add one (letters, digits and dashes, unique on the page):

<section data-node-id="hero" class="hero">…</section>

Variants only work on the page's own screen. Elements in the shared header or footer (the root page), in a parent page, or on a page at a feature's address (/store, /blog …, where the page is only a wrapper) are never swapped. Tested with a parent page.

Hands on: the page id, the screen's index, and the element's data-node-id. Check: the data-node-id appears exactly once in the screen's html.

3. Build Studio: add the variants

In Build Studio (a person): select the element, open its property panel (Props Editor) › Preset › Variants.

  1. Add variant. The first one also creates a Default entry holding what is on the canvas now. Each new variant starts as a copy of the element.
  2. Expand the variant: Source is Inline (its own markup) or Screen (another screen of this page, step 5). Add condition adds a row (source, field, operator, value). With two or more rows, Match offers ALL (AND) or ANY (OR). No conditions shows "No conditions — manual activation only": the server never picks it.
  3. To change a variant's content, pick it in the element toolbar's Variants (layers) menu and edit the canvas.
  4. Before saving, pick the Default entry again (the one in the list, not the "Default" row at the top of the menu). See the warning below.
  5. Save.

The variant on the canvas when you save is what everyone else sees. Its markup is stored in the screen's html; the others are stored aside. Visitors who match no rule get that one, even if it has rules of its own. Tested. In the toolbar menu, the "Default" row at the top does nothing, and the check mark always stays on it. New Variant in that menu switches the canvas to the new variant at once. Picking a variant from the list does switch.

Through the API, write the variants into the screen's config and send the whole screens array back (repo_update merges only top-level data keys, and page_write doesn't touch config). Keep data.html as the mirror of the active screen:

repo_update on page, id <page id>, patch: { "screens": [ …every screen, the active one with its new config… ], "html": "<the active screen's html>" }.

The stored shape (Build Studio selective-virtual-dom.ts 60–88, 1281–1291, 1427–1435):

"config": {
  "variants": {
    "hero": {
      "v-default": { "label": "Default" },
      "v-spring":  { "label": "Spring campaign",
                     "outerHTML": "<section data-node-id=\"hero\" class=\"hero\"><h1>Spring sale: 20% off</h1></section>",
                     "targeting": { "match": "all", "priority": 1,
                       "conditions": [ { "source": "request", "field": "query", "operator": "eq",
                                         "value": { "key": "utm_source", "val": "spring" } } ] } },
      "v-google":  { "label": "From Google",
                     "outerHTML": "<section data-node-id=\"hero\" class=\"hero\"><h1>Welcome, searcher</h1></section>",
                     "targeting": { "match": "all", "priority": 2,
                       "conditions": [ { "source": "request", "field": "referrer", "operator": "contains", "value": "google" } ] } }
    }
  },
  "activeVariants": { "hero": "v-default" },
  "animations": {}
}
FieldMeaning
variants.<data-node-id>.<variant id>One entry per variant. Ids are free text; Build Studio makes v-xxxxxxxx.
labelThe name shown in Build Studio
outerHTMLThe variant's whole element, replacing the element on the page. Keep the same data-node-id on it so Build Studio can swap it back. Left out on the active variant (its markup is in html).
targeting.matchall (default when missing) or any
targeting.conditions[]{ source, field, operator, value }, rows below. An empty list = never picked.
targeting.priorityLower is tried first. Missing or 0 counts as 999. Build Studio has no control for it, so variants made there are tried in the order they were made.
source / screenName"screen" and a screen's name: use that screen's HTML as the element's content (step 5). Missing = inline.
hiddentrue: when this variant wins, the element is removed. API only; tested.
activeVariants.<data-node-id>Which variant's markup is in html now

The conditions (Build Studio SettingsMenu.tsx 478–514; what the server reads: base-app build-studio/variant-engine.ts 51–109, html-variant-engine.ts 51–70). Every row was tried on a real render:

Build Studio: source › fieldStored source / fieldvalueWhat the server hasWorks?
Request › Query Paramrequest / query{ "key": "utm_source", "val": "spring" }The page URL's queryYes. Case doesn't matter.
Request › Referrerrequest / referrertextThe Referer headerYes
Request › URL Pathrequest / pathtextThe whole URL without the query: https://<host>/<page>Only with contains or endsWith. = /offer and starts with /offer never match.
Time › Hour of Daytime / hour0–23The server's clock in the server's time zoneYes, server time
Time › Day of Weektime / dayOfWeek0 (Sunday) – 6Server timeYes
Time › Monthtime / month1–12Server timeYes
Time › Datetime / dateYYYY-MM-DDThe server's UTC date= and != only. >, <, between never match (dates are compared as numbers).
Device › Device Typedevice / typemobile, tablet, desktopAlways desktopNo. desktop matches every visitor, phones included; mobile/tablet never.
Device › OS, Browserdevice / os, browserNothingNo
Device › Locale, Timezonedevice / locale, timezoneNothingNo
Device › Screen Width, Heightdevice / screen.width, screen.heightNever on the serverNo
Request › Cookie, Headerrequest / cookie, header{ key, val }Never readNo
Session › Is Logged Insession / isLoggedIntrue / falseAlways "not signed in", even for a customer's request with their tokenNo. false matches everyone; true never.
Session › Email, Username, First/Last Name, Phonesession / …NothingNo
Interaction › Scroll Depth, Time on Page, Has Clickedinteraction / …Never on the serverNo
(API only)geo / country, countryCode, city, regionNothing fills itNo

The operators (html-variant-engine.ts 72–115). Build Studio offers = != > < >= <= between for numbers and dates, = != in, not in for pick-lists, and = != contains, starts with, ends with, exists for text, query, cookie and header.

StoredTest
eq, neq, contains, startsWith, endsWithText, lower-cased on both sides
in, notInThe value as a list: an array, or comma-separated text ("0,6"). Build Studio's picker only lets you choose one.
gt, lt, gte, lteAs numbers
between[min, max] as numbers, inclusive
existsHas a non-empty value

A value the server doesn't have fails every operator, !=, not in and exists included. "Email is not x" doesn't match a visitor with no email. Tested.

A whole new page with a variant set, in one call (call_appengine PUT /repository/create; tested as written, with name/slug changed):

{
  "datatype": "page",
  "isNew": true,
  "data": {
    "name": "spring", "slug": "spring", "site": "<site name>", "appType": "web", "title": "Spring",
    "activeScreenId": "default",
    "screens": [{
      "id": "default", "name": "Default", "slug": "default", "order": 0, "attributes": {},
      "html": "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head><body><main><section data-node-id=\"hero\" class=\"hero\"><h1>Welcome</h1></section><p>The rest of the page.</p></main></body></html>",
      "config": {
        "variants": { "hero": {
          "v-default": { "label": "Default" },
          "v-spring": { "label": "Spring campaign",
            "outerHTML": "<section data-node-id=\"hero\" class=\"hero\"><h1>Spring sale: 20% off</h1></section>",
            "targeting": { "match": "all", "priority": 1, "conditions": [
              { "source": "request", "field": "query", "operator": "eq", "value": { "key": "utm_source", "val": "spring" } } ] } },
          "v-google": { "label": "From Google",
            "outerHTML": "<section data-node-id=\"hero\" class=\"hero\"><h1>Welcome, searcher</h1></section>",
            "targeting": { "match": "all", "priority": 2, "conditions": [
              { "source": "request", "field": "referrer", "operator": "contains", "value": "google" } ] } } } },
        "activeVariants": { "hero": "v-default" },
        "animations": {}
      }
    }],
    "html": "<the same document as screens[0].html>"
  }
}

Hands on: the page's new screens (with the variants) and html. Check: repo_find_one shows screens[<active>].config.variants.<data-node-id> with each variant, and activeVariants naming the version you want everyone else to see.

Stop: on a site that serves drafts, this save is live at once. Show the person each rule and its version, and wait for yes.

4. Check it on the site

Ask for the page once per rule and once with none, and read the rendered element (in a browser, or the server's HTML):

  • https://<host>/<page> shows the default version.
  • https://<host>/<page>?utm_source=spring shows the spring version.
  • With Referer: https://www.google.com/ (curl -H "Referer: …") the Google version.
  • /__preview/page/<site>/<page>?utm_source=spring runs the same rules, so it checks a draft.

How the server chose, so a surprise can be explained (html-variant-engine.ts 135–149, 233–277):

  1. Each element with variants is decided on its own.
  2. Only variants with at least one condition take part, lowest priority first.
  3. The first whose conditions pass (all / any) wins. Its outerHTML replaces the element; a hidden one removes it; a screen one fills it (step 5).
  4. If nothing passes, or the winner is the active variant, the page's own html stays as it is.

On an older page saved with data.html only (no screens), the same shape sits at data.variants and data.activeVariants (html-renderer.tsx 214–220). Tested.

Hands on: the URLs that show each version. Check: every rule shows its own version, and a request matching nothing shows the default.

5. Optional: a variant from another screen

A variant with "source": "screen", "screenName": "<screen name>" keeps the element's own tag and attributes and puts that screen's HTML inside it. Make the screen in Build Studio (it lists every screen; Convert screens to variants in the Variants panel makes one variant per screen), or add a screens[] entry through the API:

{ "id": "spring", "name": "Spring", "order": 1, "html": "<div class=\"spring\">Spring sale: 20% off</div>", "attributes": {}, "config": {} }
  • Match by name, not id.
  • Give that screen a body fragment, as above. A full document also renders (its <style> applies), but its <html>, <head> and <body> tags land inside your element.
  • That screen is public. ?screen=Spring (or its number) shows it on its own to anyone. Don't hold anything there you wouldn't show.

Convert screens to variants adds nothing on an element that has no variants yet: it writes the new variants into a throwaway copy (read from the code, SettingsMenu.tsx 886–917). Add one variant first, then convert.

Hands on: the screen's name. Check: ?<your rule> shows the screen's content inside the element.

6. The browser alternative: data-show and data-hide

When the rule is something the visitor does, or something only the browser knows, show and hide in the browser. Every version is in the page; the runtime hides the others with display: none after it loads (base-app runtime/dispatcher.ts 778–781). A Monthly / Annually switch, tested:

<div class="billing-switch">
  <button type="button" data-action="state.set" data-payload='{"path":"plan","value":"monthly"}'>Monthly</button>
  <button type="button" data-action="state.set" data-payload='{"path":"plan","value":"annual"}'>Annually</button>
</div>
<p data-hide="plan === 'annual'">$20 / month</p>
<p data-show="plan === 'annual'">$200 / year</p>
<p data-show="query.utm_source === 'spring'">Spring offer: 20% off</p>
  • query.<name> and path.* come from the URL, cart.* from the cart, customer.signedIn from the runtime, and anything you set with state.set. Every key is in The Appmint JS engine on a page.
  • Expressions can't call JavaScript functions (Number(), Math): they come back empty.
  • Hidden versions are still in the page source. Never use it to hide anything private.
Server variantdata-show / data-hide
DecidedOn the server, per request, before the page is sentIn the browser, after the runtime starts, and again whenever the state changes
What the visitor's browser getsOnly the winning versionEvery version
KnowsQuery, referrer, URL, server timeQuery, path, cart, your own state, customer.signedIn (unreliable)
Changes on a clickNoYes
Built inProps Editor › Preset › VariantsAttributes on the element
Header and footerNever swappedWorks there too

Hands on: nothing new. Check: open the page in a browser, click each button and see the right version; open it with and without the query.

Caching

A server variant doesn't break any page cache, because the site keeps none: each page request is rendered on the server for that request (the page route reads the request's headers), and the engine reads the page record each time. The local site answers Cache-Control: no-store, must-revalidate (tested). The response does not vary by User-Agent, Referer or Cookie, so a CDN set to cache the site's HTML would hand the first visitor's version to everyone at that URL (a query string is part of the URL, so query rules survive). If the site sits behind a CDN that caches pages, use query-based rules or data-show.

When it's done

Tell the person: the page and the element that changes, each version and the rule that picks it, what everyone else sees, the URLs that show each version, and any rule they asked for that can't work today and what was done instead. Open the page in Build Studio and on the site.

What it can't do yet

  • Device type, OS, browser, locale, time zone and screen size. The server reads the visitor's device from its own runtime, not the request, and keeps the first answer for every visitor after (base-app lib/activity-tracker.ts 93–151). Device type is always desktop.
  • Signed-in customers. The sign-in check reads a field the request info doesn't have (variant-engine.ts 74 reads customer; activity-tracker.ts 337 spreads the customer's fields flat), and a browser doesn't send its sign-in with the page request anyway.
  • Cookies, headers and anything the visitor does (scroll, time on page, clicks) are not read on the server.
  • Country and city. The engine has geo, nothing sets it, and Build Studio has no Geo option.
  • Customer groups, tags or purchase history. No condition for them.
  • The visitor's own time. Time rules use the server's clock and time zone; date ranges (between, >, <) never match.
  • Priority in Build Studio. Only through the API.
  • Variants in the header, footer, a parent page, or a page framing a feature.
  • On a development server (next dev) the whole page record, every variant included, is in the page source as debug data.

Don'ts

  • Don't promise a mobile, signed-in, group or country version. Say it can't be done and offer step 1's alternative.
  • Don't save with a targeted variant on the canvas unless it should be the version for everyone else.
  • Don't use = or starts with on the URL path; use contains or ends with.
  • Don't write variants on the root page; they never show.
  • Don't put private content in a screen variant or behind data-show.
  • Don't edit data.html alone on a page with screens; write the screen and keep html as its mirror.
  • Don't use 0 as a priority; it means "last".