Reading time: 9 min read

A Three-Tier Campaign Model for A/B Tests on SitecoreAI

Campaign → source code → variant: a data model that lets a campaign team run attribution and A/B tests on a headless SitecoreAI site without developer involvement.

Portrait photo of Sohrab Saboori, article author

Attribution deserves its own tier

A fundraising team runs many appeals a year. Every appeal arrives through several channels: email, paid ads, social, QR codes on printed material. Each channel needs its own attribution code so the CRM can answer "where did this donor come from." On top of that, the team wants to test presentation: different call-to-action text, different suggested amounts.

On the legacy platform, every combination of appeal, channel, and test was manual work for developers and content editors. When we rebuilt the platform headless on SitecoreAI and Next.js, we wanted the frontend to be generic and the campaign team to be self-serve: create a campaign in an admin app, get URLs and QR codes out, and have the payment form configure itself.

The piece that makes this work is the data model. This post walks through it, the config API on top, and a CDN caching mistake that taught us where A/B randomization belongs.

Why three tiers and not two

The obvious model is two levels: a campaign row plus variant rows. That misses the middle layer, and the middle layer is where attribution lives. Our hierarchy has three.

Campaign is the appeal itself. It owns status, scheduling, and campaign-wide toggles such as "accept corporate donations?".

Campaign child is one per acquisition channel, carrying that channel's source code, the attribution code the CRM reports on. It owns tracking config (target URL, UTM profiles) and the thank-you message.

Variant is an A/B presentation of the form under a child. It owns the call to action (text, colour, shape, image) and the tier configuration.

The rule of thumb that kept config at the right level: listen to how the campaign team phrases a request. "Change this for the email audience" is child-level. "Let's test this" is variant-level. "Turn this off for the whole appeal" is campaign-level.

The schema, trimmed to the essentials:

model Campaign {
  id         String         @id @default(uuid())
  slug       String?        @unique // human-readable URL key, e.g. "annual-appeal"
  name       String
  status     CampaignStatus @default(DRAFT)
  wizardData Json?          // campaign-level config (publish settings, toggles)
  children   CampaignChild[]
}

model CampaignChild {
  id         String   @id @default(uuid())
  campaignId String
  campaign   Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)

  name         String
  campaignCode String  // attribution code from the CRM; rotates over time
  sourceIndex  Int?    // stable URL slot (s1, s2, ...) assigned once, never reused
  isDefault    Boolean @default(false)

  trackingConfig Json? // { fullUrl, utmProfiles[] }
  thankYouConfig Json? // { successMessage }

  variants ChildVariant[]

  @@unique([campaignId, campaignCode])
  @@unique([campaignId, sourceIndex])
}

model ChildVariant {
  id              String        @id @default(uuid())
  campaignChildId String
  campaignChild   CampaignChild @relation(fields: [campaignChildId], references: [id], onDelete: Cascade)

  variantName      String
  ctaConfig        Json? // { ctaText, ctaColor, ctaShape, formImage }
  individualConfig Json? // tiers and frequencies for individuals
  corporateConfig  Json? // tiers and frequencies for organizations
}

Note the split between relational columns and JSON. Anything we query, join, or constrain (codes, slots, flags) is a real column with an index or a unique constraint; @@unique gives us a compound constraint backed by a unique index in the database [4]. Anything that is "form configuration" (tier lists, call-to-action options) is a JSON column. Prisma's Json fields are untyped by default [3], so the app types them with TypeScript interfaces. Config shapes churn constantly as the form evolves; the JSON columns absorb that churn without migrations, and the typed interfaces keep the app honest. The trade is that you cannot filter or sort on what is inside those columns [3], which is exactly why the queryable fields are real columns.

One more pattern worth stealing: the admin wizard edits a single JSON document (a step per screen), and the API layer decomposes it into these three tables on save and recomposes it on load. The wizard UI can be iterated freely without touching the schema, and the schema stays clean enough for the read paths that matter.

One config API for the frontend

The SitecoreAI head app renders the payment form from a single GET request. The endpoint resolves three things in order: which campaign, which child, which variant.

GET /api/sitecore/campaigns?slug=annual-appeal&source=s1&variant=2
    -> campaign by slug (preferred; falls back to id or legacy code)
    -> child by stable slot s1 (falls back to the isDefault child)
    -> variant 2 (falls back to variant 1)

The response bundles everything the form needs (tracking, call to action, tiers, thank-you message, feature toggles), so the frontend makes exactly one call. Since the endpoint is hit on every form render, it is served through the CDN:

return NextResponse.json(response, {
  headers: {
    'Cache-Control': 'public, max-age=0, s-maxage=60, stale-while-revalidate=300',
  },
});

s-maxage applies to shared caches only and is ignored by the browser, and stale-while-revalidate lets the cache keep serving the stale copy while it refreshes in the background [2]. Vercel's CDN caches a function response when it carries s-maxage, with or without stale-while-revalidate [1]. Sixty seconds of shared cache plus five minutes of stale-while-revalidate keeps origin load negligible and makes config changes visible within about a minute. That is fast enough for campaign operations, and the campaign team understands "give it a minute" intuitively.

The caching bug that taught us where randomization belongs

Our first implementation did the A/B assignment on the server: if the request did not specify a variant, the endpoint picked one at random. Simple, and completely wrong once the CDN was in front of it.

The CDN caches per URL, and Vercel's cache is segmented by region [1]. The first request after a cache miss rolled the dice, and whichever variant won that roll got cached for everyone in that region for the next cache window. The result was not a skewed test. It was no test at all, with traffic lurching between variants as cache entries expired. Nothing errored; the analytics just looked strange.

The fix inverts the responsibility: the server is deterministic per URL, the client rolls the dice. The config response includes variantCount; the frontend picks a variant once, stores the pick in localStorage so the visitor sees a consistent experience across sessions [5], and requests ?variant=N. Each variant URL now caches cleanly and independently.

Server side, selection is plain and predictable:

// Variant selection: by 1-based index or by ID. Random A/B assignment lives
// on the CLIENT. The server stays deterministic per URL so the CDN can cache
// each variant cleanly.
const variantParam = url.searchParams.get('variant');
let targetVariant: ChildVariant | null;
if (variantParam) {
  const idx = Number(variantParam);
  if (Number.isInteger(idx) && idx >= 1 && idx <= child.variants.length) {
    targetVariant = child.variants[idx - 1];
  } else {
    targetVariant =
      child.variants.find((v) => v.id === variantParam) ??
      // A stale pick (variant deleted since the visitor's last session)
      // falls back to variant 1 so the form still renders.
      child.variants[0] ?? null;
  }
} else {
  targetVariant = child.variants[0] ?? null;
}

And the client-side pick is a few lines:

// Roll once, remember the roll.
function pickVariant(campaignId: string, variantCount: number): number {
  const key = `ab:${campaignId}`;
  const stored = Number(localStorage.getItem(key));
  if (Number.isInteger(stored) && stored >= 1 && stored <= variantCount) {
    return stored;
  }
  const pick = 1 + Math.floor(Math.random() * variantCount);
  localStorage.setItem(key, String(pick));
  return pick;
}

Notice the defensive fallback on the server. A visitor can return with a localStorage pick for a variant that has since been deleted in the admin app. Out-of-range picks fall back to variant 1 instead of failing, and the client refreshes its stored pick on the next mount. Payment forms should degrade, never dead-end.

Stable URL slots, rotating codes

Attribution codes are owned by the CRM and rotate: new fiscal year, new code set. Printed material does not rotate. A QR code on a leaflet might be scanned two years after printing.

So the model separates the two lifetimes. Each child gets a sourceIndex, a stable slot (s1, s2, and so on) assigned once and never reused, and public URLs reference the slot, not the code (?source=s1). When codes rotate, an admin "remap" operation re-points slots to new codes, and every printed URL keeps working. The @@unique([campaignId, sourceIndex]) constraint enforces the never-reuse rule at the database level.

Here is what that looks like when the fiscal year turns over. The CRM team publishes the new code set: a new parent campaign and a fresh child code for every channel, with names that differ from last year's mostly by the year in them. The campaign owner opens a remap wizard against the live campaign and picks the new parent. The wizard proposes the mapping by stripping the year from each name and matching what remains, so "Email 2026" lines up with "Email 2027" without anyone typing a code. Because the campaign is live, the swap is not applied on the spot. It is scheduled for a cut-over time, the old codes stay active until then, and an hourly job applies the swap when it is due. Several campaigns can be scheduled for the same moment in one batch, with a per-campaign result so one bad row does not hold up the rest.

At the cut-over, three things are true at once. Every leaflet, poster, and email link in the wild still points at ?source=s1, and s1 now resolves to the new code, so the next donation is attributed to the new fiscal year without a single URL changing. Every past transaction keeps the code it was made under, because a remap only rotates the codes on the campaign rows; historical records are immutable snapshots. And the whole thing is auditable: each scheduled remap is a row with a status of pending, applied, cancelled, or failed, and a failed or cancelled remap leaves the live codes untouched. The tracking config, the thank-you message, and every variant survive the rotation, because none of them were ever tied to the code in the first place.

For print specifically we went one step further. QR codes encode a short redirect URL (a few characters, served by an edge worker backed by a key-value store), so even the destination page can change after printing. The database stores each short code and its destination, which means the edge store can be rebuilt from the database if the two ever drift.

Closing the loop with events

Variant config without measurement is just decoration. The same admin app exposes the events endpoint described in Replacing xDB on SitecoreAI with a Marketplace App: the frontend reports page_view, form_impression, form_submit, payment_success, and payment_failed, each tagged with campaign, child, and variant IDs plus a session ID for dedup.

That makes the per-variant funnel a single SQL GROUP BY, and the A/B comparison is conversion per variant with real payment completions as the bottom of the funnel, not clicks. One detail that proved worthwhile: form_impression is a separate event from page_view, because on long landing pages a meaningful share of visitors never scroll to where the form starts.

What we'd keep

  • Three tiers, not two. Attribution deserves its own level; flattening it into either neighbour forces duplication.
  • Config at the ownership level. Model the org chart of a campaign and "who can change what" answers itself.
  • JSON for churn, columns for queries. Both, on purpose, in the same tables.
  • Randomize on the client, stay deterministic on the server. Randomness in a cached response is a bug waiting for a CDN.
  • Public identifiers outlive internal ones. Slots and short codes outside, rotating CRM codes inside.

Sources

  1. Vercel CDN Cache — Vercel Documentation
  2. Cache-Control — MDN Web Docs
  3. Working with Json fields — Prisma Documentation
  4. Prisma schema reference: @@unique — Prisma Documentation
  5. Window: localStorage property — MDN Web Docs