Skip to content

Build a page

A routed page needs its declaration in routingcat.config.json, which names it and its routes, and its slots in your templates, written in the slot markup with every variant in the page. This guide covers both, what push stamps, the rules hidden variants follow, what keeps what visitors see measurable, and on Nuxt 2, the router hook that decides in-page navigations. To choose a page’s shape first, see Pages, variants and versions.

Declare the page

Each page is an entry under pages in routingcat.config.json, by its key, with its routes and a representative URL. init writes the rest of the file: your site and its public key, the host whose middleware decides, sources, the templates that carry the slot markup, and the head partial’s path. The example site’s:

{
"$schema": "https://cdn.routingcat.com/schemas/1.0.3/config-1.json",
"site": "routingcat-static-example",
"public_key": "pk_routingcat-static-example_Tc5Wm9Rb",
"integration": "cloudflare",
"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 are paths, with a bracketed segment for a part that varies, such as /pricing or /blog/[slug]. A path belongs to one page.
  • url is one path of the page on your production site, which doctor requests to check the page as served.
  • A page can have its own objective, with "objective" in its entry, in the same forms as the site’s. Its name starts with the page’s key, as pricing/checkout.

Routing settings per slot go under slots, and a variant’s parent, name, description and tags under variants, by its address: see Routing settings and Pages, variants and versions. Give each new variant a description of what it tries, as the example’s do: it never shows on your pages and makes no version, and show prints it above the variant’s version.

Write the slot markup

A slot is a container around its variants, written in the template that renders the page. The example’s pricing page has two slots, a line in a paragraph of its own and a word inside a sentence:

public/pricing.html
<div data-rc-page="pricing" data-rc-slot="lead" style="display:contents">
<p data-rc-variant="growth" data-rc-hash="b0918105ecaa53a328a4053a95c7f879e1296ea4e38c566ff8a21a2a2d3818ed" style="display:contents" class="lead">Free while you grow, then by billed visitors</p>
<p data-rc-variant="start" data-rc-hash="43059d727593cbc63e95432acd1a62d6b5406187fd79e98c4603d08ee63b6d35" class="lead" hidden data-nosnippet>Start free; pay only for the visitors you bill</p>
</div>
<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>
  • The container carries data-rc-page and data-rc-slot, each key written as itself, never as a template expression. It is a div, or a span inside a paragraph.
  • Each variant is an element directly inside it, carrying data-rc-variant, with nothing between variants but whitespace and comments. A variant can be the content itself, as the p elements above, or wrap it.
  • The default is the one variant without hidden: what your page shows today. The control always sees it, and so does a visitor whose browser runs no script.
  • Every other variant carries hidden, written alone, and no display in its style, which would show it without RoutingCat.
  • Template syntax inside a variant is yours, as {{ .Title }} in Hugo or {{ $title }} in Blade: the variant renders it as the page does.
  • Slots vary independently, so every combination must read well: here 2 lines and 2 trial lengths make 4. A slot can appear several times on a page, each time with the same variants, in the same order and with the same content; every copy shows the same variant.
  • Limits: at most 20 slots and 100 live variants per page. See Limits and defaults. On Hobby, your sites serve at most 10 live variants together, the defaults not counted, and hold the rest.

In a Nuxt 2 app, the markup goes in a .vue file’s template, as the Nuxt 2 example’s home page does:

pages/index.vue
<template>
<main>
<div data-rc-page="home" data-rc-slot="hero" style="display:contents">
<div data-rc-variant="current" data-rc-hash="02d1e9e4e66d2bb95e87b441483a1740ac92b73e247614c095ac42c186eb21c2" style="display:contents">
<h1>Studio headshots from your selfies</h1>
</div>
<div data-rc-variant="minutes" data-rc-hash="34e53dd1d19c668e40320c7dd977d07324c1798e31f163037e068b7080df2b1c" hidden data-nosnippet>
<h1>Professional headshots in minutes</h1>
</div>
</div>
<p><NuxtLink to="/pricing">See the pricing</NuxtLink></p>
</main>
</template>
<script>
export default {
name: 'HomePage',
}
</script>

A template in an indentation-based language, such as Haml, Slim or Pug, keeps its slots in an HTML-syntax partial that it includes.

Stamp your templates with push

Run npx routingcat push in your repository after each change to a slot, and commit what it changes:

  • It stamps each variant’s version into its start tag, data-rc-hash, right after data-rc-variant.
  • It completes the markup: style="display:contents" on a container or a default that lacks it, and data-nosnippet after hidden, which keeps a hidden variant’s text out of the snippets search engines show. It never adds or removes hidden.
  • It rewrites the head partial when this version of RoutingCat makes another, keeping the nonce your template gives its script tags.
  • It registers nothing: your build does, at each deploy.

push --dry-run lists the edits without making them. push --check changes nothing and fails while an edit is due, while the head partial is out of date, or while the config’s $schema is another version’s, so a CI can run it before each deploy. Your build fails the same way on a stamp or the partial, so a deploy never reports other versions, or runs another snippet or tracker, than it registers. After a static generator’s build, push --built checks the built pages against the templates, given their folder, as push --built public for Hugo:

$ routingcat push --built public
lumen · 1 template · 1 slot, 4 variants
built public: its slots as the templates declare them
stamps up to date
partial layouts/partials/routingcat-head.html: up to date
· Your build registers these templates on every deploy, through routingcat build. Commit what routingcat push changed; routingcat plan shows what a deploy would make live. (D72)

A built page whose slots differ from the templates fails it, each difference named with its file and line.

What makes a version. A variant’s version is its markup as written: its element and everything inside it, template syntax kept as text. A formatter’s changes are no part of it: line breaks and indentation, the quotes around attribute values and the order of attributes, where template syntax doesn’t write them, and whitespace the page doesn’t show, inside tags, around and inside block elements such as div and p, and around a br. Whitespace that shows, as between two inline elements, is part of it. So:

  • an edit inside a variant makes a new version at the next deploy, and reformatting it makes none;
  • 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;
  • data a template renders inside a variant, such as a post’s title, belongs to the template’s code: the version is the same on every page it renders;
  • an include inside a variant, such as Hugo’s {{ partial … }} or Blade’s @include, counts as the call itself, so editing the included file makes no version;
  • an image is referenced by its URL, so new bytes at the same URL make no version.

Keep hidden variants harmless

Every variant is in the page: the browser parses it, its forms submit and search engines read it. push, plan and the build check these rules, and doctor checks your pages as served:

  • Let the browser leave the slot in place. Inside a paragraph, the container and its variants are span elements, since a div would close the paragraph. A container sits inside a table cell or a list item, or around the whole table or list, never directly inside table, tr, ul, ol, dl or select, where the browser moves it out. A slot never sits inside another slot’s variant, nor in <head>.
  • Close every tag inside the variant that opens it, and in the same branch of a conditional.
  • Keep scripts and styles out of variants. A script that runs, structured data, a <style> or a stylesheet <link> acts on the page even while its variant is hidden.
  • Give each id once on the page, since hidden variants keep theirs.
  • One h1 at most per variant, and only in a slot that owns the page’s heading.
  • Keep per-visitor content out, such as a name, a cart or a sign-in state; per-item content, such as a product’s title, is fine.
  • Forms only where they work once shown and do nothing while hidden.
  • Images load alike in every variant: each image loading="lazy", the default’s included, never fetchpriority="high", and data-rc-lcp on each variant’s main image. RoutingCat preloads the main image of whichever variant a visitor gets, so no variant shows sooner than another; on Cloudflare, and for visitors who come through a Cloudflare zone in front of Vercel, once imagePreload is on.
  • Keep each variant inside its container, as written: a script that moves it elsewhere takes it out of the slot.

Keep what visitors see measurable

  • The head partial goes first in <head>, after the charset and viewport tags, on every page that can carry a slot; on Nuxt 2, RoutingCat’s entries come first in nuxt.config.js’s head.script. Its snippet shows each visitor’s variants before the page paints, and its tracker reports what each page showed. A page without it shows its defaults to every visitor.

  • The selection reaches the page in Server-Timing, a header RoutingCat’s middleware or Worker adds. A header rule or a proxy that strips it leaves visitors on the defaults, and doctor names it.

  • A Content Security Policy lets RoutingCat run in your pages when it allows:

    • the head snippet, by the page’s nonce: give the head partial’s script tags the nonce your template gives your own scripts, and push keeps it. The browser checks the nonce, not the script’s text, so it holds through your build’s rewrites and RoutingCat’s upgrades. Or by its hash in script-src, 'sha256-VA7FIBNQntYn209pbrfrTBJ1g1fF+qHfG+08SyvP+D8=', which an upgrade of routingcat can change. Hugo rewrites the snippet as it builds, so there its hash is your build’s own, which doctor names, and changes with your build;
    • the snippet’s style rules, by the same nonce, which they carry, or with 'unsafe-inline' in style-src and no hash or nonce beside it there, which would cancel it: the rules differ from one visitor to the next. 'unsafe-inline' allows the markup’s style="display:contents" too; a style-src-attr of your own takes 'unsafe-hashes' and 'sha256-BDgIqUOjni0WelhOfIRW4cmSnxZNAa0L9tCq85UnRac=';
    • the tracker, by the same nonce or with https://cdn.routingcat.com in script-src, and its beacons through /_rc, with 'self' in connect-src: without them, no page reports what it showed, and PostHog gets no variants.

    doctor checks each, and the policy’s messages say what a policy blocks.

  • Browsers too old to show a selection get the defaults, unrouted: Safari before 16.4, Chrome before 105 and Firefox before 121.

Decide Nuxt 2’s in-page navigations

A <NuxtLink>, such as the example’s link to /pricing, renders the next page in the browser, fetching at most its data, so RoutingCat’s middleware, which decides the pages your host serves, never sees that navigation. RoutingCat’s router hook decides it instead: a plugin of a few lines, which init doesn’t write. Add it as plugins/routingcat.client.js, whose .client tells Nuxt 2 to run it in the browser alone:

plugins/routingcat.client.js
export default ({ app }) => {
const { router } = app
let landed = false
let deciding = null
router.beforeEach((to, from, next) => {
const routingcat = window.routingcat
deciding =
landed && routingcat?.prepareNavigation && to.path !== from.path
? routingcat.prepareNavigation(router.resolve(to).href)
: null
next()
})
router.beforeResolve((_to, _from, next) => {
Promise.resolve(deciding).then(() => next())
})
router.afterEach(() => {
landed = true
deciding = null
window.routingcat?.navigate?.()
})
}

Then list it among the plugins in nuxt.config.js:

nuxt.config.js
export default {
// …
// RoutingCat's router hook, which decides each in-page navigation in the browser.
plugins: ['~/plugins/routingcat.client.js'],
// …
}
  • Before each navigation to another path, routed or not, the hook asks your site’s /_rc path for the next page’s variants, beside Nuxt’s own fetch of the page’s data, and the router waits for the answer, a second at most. The next page renders with its variants shown, or with its defaults when no answer comes in time.
  • Once the address shows the next page, the tracker reports what its slots showed, as it does for a page your host served.
  • The first page isn’t asked for again: your host decided it as it served it.
  • Where the tracker doesn’t load, as when a blocker stops it, the hook does nothing, and navigations go on as before.

Next