Skip to content

Client-rendered apps

An app that renders in the browser, such as a React app built with Vite, works as any site RoutingCat decides in the page: its head partial picks each visitor’s variants, its templates carry the slots, and push registers them. This guide covers what differs: where the partial goes, the two ways a component renders a slot, and how each route change counts. Build a page has the rules every slot follows. The example is a React app built with Vite, with two pages, / and /pricing.

Set up the app

In the app’s folder, npx routingcat init recognizes an app built with Vite, by the index.html at its root, or with Create React App, by its public/index.html. It sets the app up as Set up RoutingCat shows, with these differences:

  • Your templates are that index.html and the HTML files under src/, since a React component can’t carry the slot markup push reads. In a Vue app, they are the index.html and the .vue files under src/.
  • The head partial, routingcat-head.html, goes into the index.html, which includes no file: init prints the edit. Paste the partial’s content first in its <head>, after the charset and viewport tags, and again each time push changes it, then commit both. Your build copies it into the page it writes.
  • The partial takes each route change as a view, as below.

The example app’s config, with its objective and the outcome it tracks beside it:

{
"$schema": "https://cdn.routingcat.com/schemas/1.0.3/config-1.json",
"site": "routingcat-vite-example",
"public_key": "pk_routingcat-vite-example_Rv7Kq3Wd",
"integration": "script",
"routingcat": "1.0.3",
"environments": {
"production": {
"hosts": ["vite.example.com"]
}
},
"sources": ["index.html", "src/**/*.html"],
"head": { "partial": "routingcat-head.html" },
"objective": "signup",
"outcomes": ["purchase"],
"pages": {
"home": { "routes": ["/"], "url": "/" },
"pricing": { "routes": ["/pricing"], "url": "/pricing" }
}
}

Write a slot with every variant

A slot written in the slot markup, every variant in it, needs no code of RoutingCat’s in your components. The example’s home page keeps its headline in src/slots/hero.html, as push stamped it:

src/slots/hero.html
<div data-rc-page="home" data-rc-slot="hero" style="display:contents">
<div data-rc-variant="current" data-rc-hash="58c2042d15ca41d3269bcc357d905fd4f509d74006e38b53a11ebb6edcf2af0d" style="display:contents">
<h1>Edit photos like a pro</h1>
</div>
<div data-rc-variant="oneclick" data-rc-hash="c4a2d99ee3bdf59575744430bd29c6ab8d80e4fbb8cb25f7ff7556218754ed67" hidden data-nosnippet>
<h1>Pro-level photo edits in one click</h1>
</div>
</div>

The page renders it once, as written, from the file’s text, which Vite’s ?raw import gives:

src/Home.tsx
import { SignUp } from './SignUp'
import hero from './slots/hero.html?raw'
export function Home() {
return (
<main>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: the site's own markup, built in */}
<div style={{ display: 'contents' }} dangerouslySetInnerHTML={{ __html: hero }} />
<p>Retouch, crop and color your photos in the browser.</p>
<SignUp />
</main>
)
}

RoutingCat’s style rules show the variant each view picked, and no re-render of yours undoes them. Every variant is in the page, so the rules that keep what visitors see measurable apply as written.

Render the picked variant alone

A component can instead render only the variant RoutingCat picked, as the example’s pricing page does for its plan. The variants are declared in src/slots/plan.html, which push stamps, and which renders nothing itself:

src/slots/plan.html
<div data-rc-page="pricing" data-rc-slot="plan" style="display:contents">
<div data-rc-variant="monthly" data-rc-hash="0907e445ef3c48244ce235e937ee59f5d93fc3adf75a8847580d892057f9a853" style="display:contents">
<p>Pro, $12 a month, cancel any time.</p>
</div>
<div data-rc-variant="annual" data-rc-hash="dfc750f35d56188d8d271696924bec69c38303b49fc6bc35f3ad85cd7201ec37" hidden data-nosnippet>
<p>Pro, $96 a year: four months free.</p>
</div>
</div>

The component waits for the page’s variants, then renders the picked variant’s content in one element that names the page, the slot, the variant and its version’s hash:

src/Pricing.tsx
import { useEffect, useState } from 'react'
import declared from './slots/plan.html?raw'
const slot = document.createElement('template')
slot.innerHTML = declared
/** A variant child of the slot's markup: the one decided, else the default, not hidden. */
const variantOf = (key: string | null): Element =>
(key === null ? null : slot.content.querySelector(`[data-rc-variant="${key}"]`)) ??
(slot.content.querySelector('[data-rc-variant]:not([hidden])') as Element)
function Plan() {
const [decided, setDecided] = useState(window.routingcat === undefined)
useEffect(() => {
let mounted = true
window.routingcat?.ready('pricing').then(() => {
if (mounted) setDecided(true)
})
return () => {
mounted = false
}
}, [])
if (!decided) return null
const variant = variantOf(window.routingcat?.variant('pricing', 'plan') ?? null)
return (
<div
style={{ display: 'contents' }}
data-rc-page="pricing"
data-rc-slot="plan"
data-rc-variant={variant.getAttribute('data-rc-variant') ?? undefined}
data-rc-hash={variant.getAttribute('data-rc-hash') ?? undefined}
// biome-ignore lint/security/noDangerouslySetInnerHtml: the site's own markup, built in
dangerouslySetInnerHTML={{ __html: variant.innerHTML }}
/>
)
}
  • window.routingcat.ready('pricing') resolves once RoutingCat has picked the page’s variants, or once its wait for routing data has ended and the page keeps its defaults.
  • window.routingcat.variant('pricing', 'plan') then gives the key of the variant picked for the slot, or null where there is none, as when the page keeps its defaults: the example then renders the default.
  • The element’s four attributes, data-rc-page, data-rc-slot, data-rc-variant and data-rc-hash, tell RoutingCat which variant and version the slot showed, which it reports for the view.
  • Until the page is decided, the component renders nothing. Where RoutingCat’s script doesn’t run, window.routingcat is undefined, and the component renders the default.
  • Change the address before you render the next page, as the example’s router does below: ready and variant answer for the view RoutingCat starts when the path changes.

The JavaScript API gives each call’s signature.

Each route change is a view

The partial init writes for your app takes each change of the address’s path as a new view of the page at that path, as a page load is: RoutingCat picks the page’s variants, keeping those the visitor already had on that page in this visit, records the view, and reports what its slots show. It follows the address through the browser’s history, so your router calls nothing of RoutingCat’s: window.routingcat.view(), the call it makes at each change of path, starts a view for the current address. A change of the query string or the # fragment alone stays in the same view.

The example’s router changes the address, then renders the next page:

src/App.tsx
const follow = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
window.history.pushState(null, '', event.currentTarget.href)
setPath(window.location.pathname)
}

Track conversions

The page reports a conversion with window.routingcat.track(), as the example’s sign-up does:

src/SignUp.tsx
const signUp = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
void window.routingcat?.track('signup')
setSignedUp(true)
}

The call never throws, and sends the visitor id itself. A conversion your server sees, such as an account it creates, is safer sent from there: Track conversions gives both ways.

Type window.routingcat

In TypeScript, routingcat/runtime gives the type of window.routingcat, with routingcat among your dev dependencies, at the version your config pins. The example declares it optional, since a page whose RoutingCat script doesn’t run has none:

src/routingcat.d.ts
import type { RoutingCatPage } from 'routingcat/runtime'
declare global {
interface Window {
routingcat?: RoutingCatPage
}
}

Deploy

Your build copies the index.html, the partial in it, into the page it writes, so the app deploys as before. Register each production deploy with push --check before it and push right after, as on any site RoutingCat decides in the page: Change, preview and deploy gives the example’s GitHub Actions workflow. On your machine, Vite’s development server runs your pages in development mode, where ?rc= shows a variant, as See a variant in development describes.

Next