Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

Personalization, A/B testing and analytics

Spillway can show different content to different visitors, run A/B tests on any part of a page and count traffic without cookies or a third-party script. All of it runs inside the one server process; nothing identifies a visitor and pages stay cacheable.

How islands keep pages cacheable

Pages are rendered once per path and cached in memory (see architecture.md). A Personalize or Experiment block cannot be baked into that cached HTML, so the server renders it as an island:

<div class="sw-island sw-personalize" data-sw-island="<entityId>/<blockId>" data-sw-audience="newsletter">
  …the default content…
</div>

The cached page carries the default content (the default slot, or variant a), which is what crawlers, feed readers and visitors without JavaScript get. A 1.3 KB inline script, injected after the alerts script at the end of <body>, runs after DOMContentLoaded, calls GET /_island/<entityId>/<blockId>?u=<page url>&r=<document.referrer> for every island and swaps the returned markup in. The island response is never cached (Cache-Control: no-store, Vary: Cookie) and is where matching and assignment happen: from the page URL (utm and query parameters, path), the referrer, the visitor's cookies and the user agent. Nothing about the visitor is stored.

Put data-sw-no-island on any ancestor element (or on <html> through custom markup) to leave islands alone; the default content then stays.

Pages that contain islands are tagged audience:<key> and experiment:<key>, so saving an audience or changing an experiment evicts them. Draft previews (/_preview/:id) render the default content inline with no island.

Audiences

Audiences (Marketing → Audiences, for managers and admins) are named rule sets with a key. Rules:

RuleMatches when
Campaign parameterutm_source, utm_medium, utm_campaign, utm_content or utm_term in the page URL equals a value (case-insensitive)
Query parametera parameter is present in the page URL, optionally with a given value
Referrer containsdocument.referrer contains a string, e.g. linkedin.com
Returning visitorthe first-party cookie sw_v=1 is present (set by the island response on the first visit; see consent mode)
Page path starts withthe page path starts with a prefix, e.g. /programs
Time windownow is after and/or before an ISO date-time (server clock, UTC)
Devicethe user agent is a phone or tablet (mobile) or anything else (desktop)

An audience matches when all or any of its rules match (your choice); an audience with no rules matches nobody.

The Personalize block has an audienceKey field and two slots, Default content and Content for the audience. Keep the two the same shape (a Hero in each, for instance) so the swap does not shift the page. In the page builder both slots are shown stacked with labels.

Experiments

Experiments (Marketing → Experiments, for managers and admins) hold the variants, their weights and the goal:

  • Variants a (the control), b and optionally c, each with a label and a relative weight (50 / 50 is an even split; 0 excludes a variant).
  • Goal: a form submission (by form key), a visit to a page path (for instance /thank-you) or a click inside a CSS selector (for instance .sw-button--primary).
  • Status: draft → Start → running → Pause / Resume → End → ended. Only a running experiment assigns variants; every other status shows a. Ending is final.

The Experiment block has an experimentKey field and slots A, B and C. Create the experiment first, then place the block, enter the key and fill the slots.

Assignment is weighted and sticky: the island response sets the cookie sw_x=<expKey>:<variant>|<expKey>:<variant> for 90 days (SameSite=Lax), and a visitor keeps their variant until the experiment ends or the variant is removed. This cookie is functional (it holds no identifier) and is set regardless of consent mode.

Results

Results on an experiment shows, per variant, exposures (visitors who saw it), conversions and the rate, as a bar per variant, plus the verdict of a two-proportion z-test between the best variant and the runner-up. A winner is declared at 95% confidence or more; below that the screen shows the confidence reached so far. Counts start at the experiment's start date.

Exposures come from the beacon (below) once per visitor per experiment per day. Conversions are counted once per visitor per experiment per day:

  • form goals when a submission of that form is stored, attributed through the sw_x cookie sent with the submission;
  • path goals when the beacon reports a view of that path by an assigned visitor;
  • click goals when the island script sees a click inside the selector and posts { e: 'click', x: '<expKey>' } to /_beacon.

Analytics

The counter needs no cookie and no consent: the island script sends one navigator.sendBeacon('/_beacon', …) per page view with the path, the referrer host, the utm_* parameters, the experiment exposures and the viewport width. The server keeps daily counts only:

  • views and unique visitors per day, per path, per referrer host and per utm_source;
  • exposures per experiment and variant;
  • conversions per goal (form key, path or experiment key) and, when attributed, per variant.

Unique visitors are deduplicated with a hash of the IP address and user agent, salted with a random value that changes every day and never leaves memory. The hash is not written anywhere; after a restart the same visitor counts again that day, so "visitors" is a lower bound, and nothing in the database can be tied to a person. User agents that look like bots (crawlers, headless browsers, HTTP libraries, monitoring tools, or no user agent at all) are not counted. The beacon endpoint accepts 60 requests per minute per IP address and bodies up to 4 KB, and always answers 204. Rows older than 400 days are deleted when the server starts.

Analytics (in the sidebar, for anyone who can publish or manage settings) shows a date range (7, 30 or 90 days, or any from/to), views and visitors as stat tiles and a line chart per day (hover for the exact numbers; "Show as table" lists them), the top pages (click one for its daily views), top referrers, top campaign sources and the conversions per goal. The dashboard carries a seven-day traffic card.

The counter complements, and does not replace, Google Tag Manager, GA4 or Plausible from Site settings → Integrations: those tags are injected as before and never see the beacon.

Consent mode

Site settings → Integrations → Privacy has one switch, consent mode:

  • Off (none): tags load at once and the island response sets the returning-visitor cookie sw_v on the first visit.
  • Consent banner (banner): a small banner (Accept / Decline) is shown until the visitor answers; the answer is kept for a year in sw_consent=1|0. Google tags start with gtag('consent', 'default', …) storage denied and are updated on accept; the sw_v cookie is set only after sw_consent=1, so the "returning visitor" rule never matches a visitor who declined. The analytics counter is unaffected because it sets no cookies.

API

Admin routes need site.settings; the id in a path may also be the key.

MethodPathNotes
GET / POST/api/audiencesAudience[]; { key, label, rules, match } → Audience (201).
GET / PUT / DELETE/api/audiences/:idPUT takes a partial input. Saving evicts pages tagged audience:<key>.
GET / POST/api/experimentsExperiment[]; `{ key, label, variants: [{ key: 'a'
GET / PUT / DELETE/api/experiments/:idVariants must include a, be unique and have a positive total weight.
POST/api/experiments/:id/start, /pause, /endStatus transitions; startedAt is set on the first start, endedAt on end; an ended experiment cannot restart (409).
GET/api/experiments/:id/results{ experimentId, variants: [{ key, exposures, conversions, rate }], winner?, confidence? }.
GET/api/analytics/overview?from=&to=site.settings or content.publish. { from, to, views, visitors, byDay, topPages, topReferrers, topSources, conversions }; defaults to the last 30 days, at most 400.
GET/api/analytics/pages/:path?from=&to={ path, from, to, views, visitors, byDay } for one path.
GET/_island/:entityId/:blockId?u=&r=Public. { html, exposure?: { experiment, variant }, goal?: { experiment, selector } }; sets sw_x and, when allowed, sw_v. 404 for anything that is not a published island block.
POST/_beaconPublic, no CSRF. { p, r, u, x, w } for a view or { e: 'click', x } for a click goal; always 204 (429 over the limit, 413 over 4 KB).

PUT /api/site accepts privacy: { consentMode: 'none' | 'banner' }.

Spillway 0.1.0 · Edit this page on GitHub

Was this page helpful?