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 rule | Use | Why |
|---|---|---|
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 URL | Server 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 date | Server variant (Time) | Server time and time zone, not the visitor's |
| A Monthly / Annually switch, a tab the visitor clicks | data-show with state.set (step 6), or wm-tabs | It changes after the page loads; a server variant can't |
| Mobile vs desktop | CSS media queries in the page <head> | The server always sees "desktop"; the runtime has no device key |
| Signed in vs signed out | Neither, reliably. The customer portal for anything private | The server never sees the sign-in; customer.signedIn can read true for an anonymous visitor |
| A customer group, a tag, a past purchase | Not possible on a page today | No condition for it |
| A country or city | Not possible today | The 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.
- 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.
- 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.
- To change a variant's content, pick it in the element toolbar's Variants (layers) menu and edit the canvas.
- 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.
- 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": {}
}| Field | Meaning |
|---|---|
variants.<data-node-id>.<variant id> | One entry per variant. Ids are free text; Build Studio makes v-xxxxxxxx. |
label | The name shown in Build Studio |
outerHTML | The 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.match | all (default when missing) or any |
targeting.conditions[] | { source, field, operator, value }, rows below. An empty list = never picked. |
targeting.priority | Lower 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. |
hidden | true: 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 › field | Stored source / field | value | What the server has | Works? |
|---|---|---|---|---|
| Request › Query Param | request / query | { "key": "utm_source", "val": "spring" } | The page URL's query | Yes. Case doesn't matter. |
| Request › Referrer | request / referrer | text | The Referer header | Yes |
| Request › URL Path | request / path | text | The whole URL without the query: https://<host>/<page> | Only with contains or endsWith. = /offer and starts with /offer never match. |
| Time › Hour of Day | time / hour | 0–23 | The server's clock in the server's time zone | Yes, server time |
| Time › Day of Week | time / dayOfWeek | 0 (Sunday) – 6 | Server time | Yes |
| Time › Month | time / month | 1–12 | Server time | Yes |
| Time › Date | time / date | YYYY-MM-DD | The server's UTC date | = and != only. >, <, between never match (dates are compared as numbers). |
| Device › Device Type | device / type | mobile, tablet, desktop | Always desktop | No. desktop matches every visitor, phones included; mobile/tablet never. |
| Device › OS, Browser | device / os, browser | Nothing | No | |
| Device › Locale, Timezone | device / locale, timezone | Nothing | No | |
| Device › Screen Width, Height | device / screen.width, screen.height | Never on the server | No | |
| Request › Cookie, Header | request / cookie, header | { key, val } | Never read | No |
| Session › Is Logged In | session / isLoggedIn | true / false | Always "not signed in", even for a customer's request with their token | No. false matches everyone; true never. |
| Session › Email, Username, First/Last Name, Phone | session / … | Nothing | No | |
| Interaction › Scroll Depth, Time on Page, Has Clicked | interaction / … | Never on the server | No | |
| (API only) | geo / country, countryCode, city, region | Nothing fills it | No |
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.
| Stored | Test |
|---|---|
eq, neq, contains, startsWith, endsWith | Text, lower-cased on both sides |
in, notIn | The value as a list: an array, or comma-separated text ("0,6"). Build Studio's picker only lets you choose one. |
gt, lt, gte, lte | As numbers |
between | [min, max] as numbers, inclusive |
exists | Has 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=springshows the spring version.- With
Referer: https://www.google.com/(curl -H "Referer: …") the Google version. /__preview/page/<site>/<page>?utm_source=springruns 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):
- Each element with variants is decided on its own.
- Only variants with at least one condition take part, lowest
priorityfirst. - The first whose conditions pass (
all/any) wins. ItsouterHTMLreplaces the element; ahiddenone removes it; a screen one fills it (step 5). - If nothing passes, or the winner is the active variant, the page's own
htmlstays 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, notid. - 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>andpath.*come from the URL,cart.*from the cart,customer.signedInfrom the runtime, and anything you set withstate.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 variant | data-show / data-hide | |
|---|---|---|
| Decided | On the server, per request, before the page is sent | In the browser, after the runtime starts, and again whenever the state changes |
| What the visitor's browser gets | Only the winning version | Every version |
| Knows | Query, referrer, URL, server time | Query, path, cart, your own state, customer.signedIn (unreliable) |
| Changes on a click | No | Yes |
| Built in | Props Editor › Preset › Variants | Attributes on the element |
| Header and footer | Never swapped | Works 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.ts93–151). Device type is alwaysdesktop. - Signed-in customers. The sign-in check reads a field the request info doesn't have (
variant-engine.ts74 readscustomer;activity-tracker.ts337 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
=orstarts withon the URL path; usecontainsorends with. - Don't write variants on the
rootpage; they never show. - Don't put private content in a screen variant or behind data-show.
- Don't edit
data.htmlalone on a page with screens; write the screen and keephtmlas its mirror. - Don't use
0as a priority; it means "last".