Build a page
A routed page on your site is a page whose templates carry every variant of each slot, whose routes routingcat.config.json declares, and whose <head> includes RoutingCat’s head partial. This guide covers each, the rules that keep what visitors see measurable, and the optional server helpers. To choose a page’s shape first, see Pages, variants and versions.
Declare the page
Each page is a key under pages in routingcat.config.json, with its routes and a representative path. The example app’s config, for a site of plain HTML pages:
"sources": ["public/**/*.html"], "head": { "partial": "routingcat-head.html" }, "objective": "waitlist", "pages": { "home": { "routes": ["/"], "url": "/" }, "pricing": { "routes": ["/pricing"], "url": "/pricing" } }, "variants": { "home/hero:learns": { "description": "Lead with learning" }, "home/hero:shows": { "description": "Lead with each visitor's own page" }, "home/subhead:short": { "description": "Half the words" }, "pricing/lead:start": { "description": "Lead with the free start" }, "pricing/trial:days30": { "description": "A longer trial" } }- Keys are lowercase letters, digits,
-and_, up to 40 characters. Addresses are built from them:home,home/hero,home/hero:learns. - Routes name the paths that are the page, such as
/pricingor/blog/[slug]. A path belongs to one page. urlis one path of the page on your production host, whichdoctorreads to check the page as served.- A page can have its own objective, with
objectiveunder the page, in the same forms as the site’s. - Slots and variants come from your templates, which
sourceslists as globs. The config names a slot only to set its routing, and a variant only to set its parent or to say what it tries, as below. - Each new variant says what it tries in its
description, undervariantsby its address, as the example’s do, with anameandtagsif you like. They never show on your pages and make no version:showprints them above the variant’s version.
The config’s other fields:
| Field | What it holds |
|---|---|
$schema |
The file’s JSON Schema, which your editor reads: push writes the one of the routingcat version that runs it |
site, public_key, integration |
Your site, its public key and script, as init wrote them |
routingcat |
The version of the routingcat command your site runs, which writes your head partial: npx routingcat runs it, as Set up RoutingCat explains |
environments |
production.hosts, where pages decide and record as production, each exact or *. and a domain; production.branch, the branch link github takes; preview.hosts, where pages record apart from your results. Any other host runs in development mode |
head.partial |
Where push writes the head partial |
objective, outcomes |
The main conversion, and the outcomes tracked beside it |
slots |
Each slot’s routing, by its address: fixed_share, weights, personalization, floor and cap, as Routing settings describes them |
variants |
Each variant’s parent and relation, and its name, description and tags, by its address |
wait_ms |
How long a first view waits for its routing data: 1,000 ms by default, at most 10,000 |
helpers |
The server helpers your server runs, below |
Config and declarations has every field with its rules.
Limits:
- at most 20 slots and 100 live variants per page, and on Hobby, the 10 live variants the account’s sites share, the defaults not counted, past which a variant is held;
- at most 128 KB of routing data per page, which browsers fetch:
pushwarns above 96 KB, and past 128 KB, every visitor gets the page’s defaults, unrouted, until a push brings it under. Retiring variants or turning personalization off for a slot makes it smaller; - at most 16 KB for the list of pages, slots and variants that
pushwrites into the head partial, which every page reads first.
Write the slot markup
A slot is an element of your template that holds every variant. The example app’s home page has two slots, its headline and the line under it, as push stamped them:
<div data-rc-page="home" data-rc-slot="hero" style="display:contents"> <div data-rc-variant="current" data-rc-hash="214ccf813b46030bb649a04128e2863d5ace330ed9b0088cc64e18a2286cc6eb" style="display:contents"> <h1>Route every visitor to what converts</h1> </div> <div data-rc-variant="learns" data-rc-hash="f0197a0807eb3b098f2e1fb122e5e62a89ef6da4e7fc72f8dae6b0e4b5599202" hidden data-nosnippet> <h1>Your pages learn what converts each visitor</h1> </div> <div data-rc-variant="shows" data-rc-hash="772f7208a1f629e488de631d7b939443bb84344293a1035ea876380fe41eaf31" hidden data-nosnippet> <h1>Show each visitor the page that converts them</h1> </div> </div> <div data-rc-page="home" data-rc-slot="subhead" style="display:contents"> <p data-rc-variant="current" data-rc-hash="3175d46fa22c50d0648103bd8de0090e99780b1d89e5f85f1416154306dbc634" style="display:contents">Each visitor sees the version of the page most likely to convert them, and you see which version works for whom.</p> <p data-rc-variant="short" data-rc-hash="e27b721a7d83e09079328d5b26778c5bdf48b70c793ded197b0bc3c1f4469800" hidden data-nosnippet>Every visitor gets the page that works for them. You see what works.</p> </div>| Part | The rule |
|---|---|
| The container | Carries data-rc-page and data-rc-slot, the page’s key and the slot’s, written as themselves, never as a template expression |
| Its children | Every child is a variant, carrying data-rc-variant, its key. Between them, only whitespace and comments |
| The default | Exactly one variant without hidden: what your site shows today, which the control always sees |
| Every other variant | hidden, written alone, and no display in its style, which would show it on a page where RoutingCat’s script doesn’t run |
You write the keys and hidden; push writes the rest. npx routingcat push stamps each variant’s data-rc-hash, the version of its content, adds style="display:contents" to the container and the default, so the slot adds no box to your layout, and adds data-nosnippet to each hidden variant, so search engines don’t show its text in their results’ snippets. It never adds or removes hidden. Commit what it changes.
Inside a paragraph, a slot is span elements, container and variants, since a div would close the paragraph. The example’s pricing page varies the length of a trial inside a sentence:
<p>Start your <span data-rc-page="pricing" data-rc-slot="trial" style="display:contents"><span data-rc-variant="days14" data-rc-hash="55e70241ccd8f3434567706bbe2cfec639c4a80c72096d941822d2f79fa1e95f" style="display:contents">14-day</span><span data-rc-variant="days30" data-rc-hash="196d9ca52c6ba10b0bd7d6838fbc4e30cb89b84cd4290e524da094871695c691" hidden data-nosnippet>30-day</span></span> trial.</p>A variant’s version is its source, as your template writes it. Template syntax inside a variant is hashed as written, so a variant can show each item’s own data, such as a product’s title, and stay one version. An include inside a variant, such as @include or {% include %}, is hashed as the call: editing the included file makes no new version. What marks the variant that shows, hidden, data-nosnippet and the default’s style="display:contents", is no part of a version, nor is the stamp, so moving the default to another variant, as after a promote, makes none. A slot written in several templates carries the same variants, in the same order, with the same content, in each.
push refuses what it can’t read, with the reason: a key written as a template expression, a variant that doesn’t close inside itself, or closes in another branch of a conditional than it opens, and slots in templates indented without tags, such as Haml, Slim or Pug, whose slots go in an HTML partial they include:
$ routingcat push✗ layouts/index.html:6: data-rc-variant is written as a template expression, {{ .Params.hero }}, so routingcat cannot read this slot from your templates. Write the key itself: lowercase letters, digits, - and _. (D66, exit 1)A variant can be a <template> element carrying data-rc-variant, with data-rc-as="span" inside a paragraph. The browser doesn’t render its content, and RoutingCat copies it into the page only for visitors it is selected for, so it suits variants with many elements or images. Use it only where no script of your own re-renders the slot’s HTML.
In an app that renders in the browser, such as a React app built with Vite, your components render the slots from HTML files, or render the variant RoutingCat picked alone: see Client-rendered apps.
Keep what visitors see measurable
Hidden variants stay in the page, and each visitor’s browser shows one per slot with style rules alone. These rules keep that true, and push and plan check them:
- Keep each slot where the browser leaves it. Never put a container directly inside a table, a table row, a list or a
select, where the browser moves it: put it inside the cell or the item, or around the whole list. Slots don’t nest, and<head>holds none. A script of your own that moves a variant’s elements out of their container takes them out of RoutingCat’s rules. - Keep what is hidden inert. No script that runs and no structured data inside a variant, since both act while hidden; no stylesheet, which styles the whole page; no id given twice on the page; at most one
h1per variant, only in a slot that owns the page’s heading. A form inside a variant must do nothing while hidden and work once shown. - Keep variants the same for every visitor. Content that differs per visitor, such as a name, the cart, a sign-in state or a CSRF token, stays outside slots: a variant is the same for everyone who sees it.
- Load images the same way in every variant. Every image inside a variant, the default’s included, carries
loading="lazy", and nonefetchpriority="high", so hidden images are never fetched. Mark each variant’s main imagedata-rc-lcp: RoutingCat preloads it for whichever variant shows, so no variant loads faster than another.
Weigh a variant your server honors
A variant your server or checkout acts on, such as a price, a discount, a shipping threshold or an offer, is a trade-off on your site. Each visitor’s browser picks their variants at random, from routing data that anyone can read, so a determined visitor could pick the better variant, such as the lower price, while most visitors browse normally. Variants a visitor only reads, such as a headline, are unaffected.
RoutingCat accepts any variant, so the choice is yours: your agent explains this before it builds such a variant, then does as you decide. If you go ahead with a price, give its slot a fixed share, a classic A/B test, as Routing settings explains. On a host whose middleware runs RoutingCat, such as Vercel or Cloudflare, your server picks the variants instead, out of any visitor’s reach.
Include the head partial
Each page’s <head> includes the partial push writes, first, after the charset and viewport tags. It holds:
- two preconnects, to
ingest.routingcat.comandcdn.routingcat.com; - your pages, slots and variants with their versions’ hashes, and the hosts of each environment, as JSON,
<script type="application/json" id="rc-head">; - RoutingCat’s core,
<script id="rc-core">, inline: it picks the visitor’s variants and shows them before the page’s body is read; - RoutingCat’s post part, from
cdn.routingcat.com, pinned by its version and its integrity hash and loadeddefer: it reports what each slot showed and refreshes the page’s routing data.
Never edit it, except to give its script tags your page’s nonce, where your Content Security Policy runs scripts by nonce: push keeps the nonce each time it writes the partial, as Run it in production explains. push writes it again whenever your slots or your config’s declarations change, from the same scan as its stamps, so the two always agree; your CI’s push --check fails on a partial that differs, and doctor on a page that serves one. Its scripts hold none of the sequences a template language would read, so Blade, ERB, Django, Jinja, Hugo, Jekyll, Eleventy, PHP and plain HTML include it as it is. Set up RoutingCat gives the line that includes it in each.
Its code changes only when you choose. The partial carries the version of RoutingCat’s browser code that your site’s routingcat version pins, so your visitors get newer code only after you raise that version, run push and deploy. doctor reports a newer version.
Add the server helpers
Where your own server renders the page, as Laravel, Rails, Django, WordPress or PHP does, two cookies it sets improve what RoutingCat records:
rc_vid, the visitor id, for 400 days. Without it, the id is written by script, which Safari deletes after 7 days without a visit, so one person can count as several visitors in a month.rc_fs, the fetch site, which tells a visit typed or bookmarked from one that followed a link: without it, a visit with no referrer is counted asunknownrather thandirectin sources.
npx routingcat init --helpers writes them in your stack’s language, adds the line that runs them where one line of one file shows where, else prints it, and declares them in routingcat.config.json, as "helpers": { "cookie": true, "fetch_site": true }. Commit what it changes and deploy.
| Stack | The helpers | Run by |
|---|---|---|
| Laravel | app/Http/Middleware/RoutingCatHelpers.php |
bootstrap/app.php’s withMiddleware, appended as global middleware; before Laravel 11, $middleware in app/Http/Kernel.php |
| Rails | app/controllers/concerns/routing_cat_helpers.rb |
include RoutingCatHelpers in ApplicationController |
| Django | routingcat_helpers.py |
'routingcat_helpers.RoutingCatHelpers', first in MIDDLEWARE |
| WordPress | routingcat-helpers.php |
require_once __DIR__ . '/routingcat-helpers.php'; in the theme’s functions.php |
| PHP | routingcat-helpers.php |
A require_once of it first in each page, before any output |
Lumen’s Laravel helpers, as init --helpers wrote them in app/Http/Middleware/RoutingCatHelpers.php:
<?php
namespace App\Http\Middleware;
use Closure;use Illuminate\Http\Request;use Symfony\Component\HttpFoundation\Cookie;use Symfony\Component\HttpFoundation\Response;
// RoutingCat's server helpers, which npx routingcat init --helpers wrote and// routingcat.config.json declares: on each page your server renders, the visitor cookie rc_vid,// which Safari keeps 400 days when a server sets it, not 7, and the fetch-site cookie rc_fs, which// tells RoutingCat a visit typed or bookmarked from one that followed a link. Neither runs on a// page a cache serves.// Global middleware, outside the web group: it reads the cookies before the next middleware runs,// since EncryptCookies decrypts the request's in place and nulls one it cannot, as rc_vid, set raw;// and it writes its own after EncryptCookies encrypts the others, as the browser reads them.class RoutingCatHelpers{ public function handle(Request $request, Closure $next): Response { $cookies = self::cookies($request->server->all(), $request->cookies->all()); $response = $next($request); foreach ($cookies as [$name, $value, $age, $domain]) { $response->headers->setCookie( Cookie::create($name, $value, time() + $age, '/', $domain, true, false, true, 'lax'), ); } return $response; }
/** The cookies a page's response sets, as [name, value, max-age, domain]. */ public static function cookies(array $server, array $cookies): array { $dest = $server['HTTP_SEC_FETCH_DEST'] ?? 'document'; if ($dest !== 'document' || isset($server['HTTP_SEC_PURPOSE'])) { return []; } $b64 = fn (string $bytes): string => rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); $vid = $cookies['rc_vid'] ?? ''; if (!is_string($vid) || !preg_match('/^v_[A-Za-z0-9_-]{22}$/D', $vid)) { $vid = 'v_' . $b64(random_bytes(16)); } $set = [['rc_vid', $vid, 34560000, 'lumen.com']]; $sites = ['none' => 'n', 'cross-site' => 'x', 'same-site' => 's', 'same-origin' => 'o']; $site = $sites[$server['HTTP_SEC_FETCH_SITE'] ?? ''] ?? null; if ($site !== null && ($server['REQUEST_METHOD'] ?? 'GET') === 'GET') { $digest = $b64(substr(hash('sha256', $server['REQUEST_URI'] ?? '/', true), 0, 6)); $set[] = ['rc_fs', "1.$site.$digest", 10, null]; } return $set; }}And, in bootstrap/app.php, the line that runs them, beside the one init adds on every Laravel site, which leaves rc_vid out of its cookie encryption so that your own code reads the cookie as the browser sent it:
->withMiddleware(function (Middleware $middleware): void { $middleware->encryptCookies(except: ['rc_vid']); $middleware->append(\App\Http\Middleware\RoutingCatHelpers::class); // })Before Laravel 11, init adds 'rc_vid' to $except in app/Http/Middleware/EncryptCookies.php instead.
They run only on pages your server renders. A shared cache in front of your pages, such as Cloudflare’s Cache Everything, Varnish or a WordPress page cache, either strips their cookies or stops keeping the page, and a page it answers runs no helper. Let your routed pages through such a cache, or leave the helpers off in routingcat.config.json and run push: its views then record what the browser alone can tell. doctor checks that each page sets the cookies the partial declares.