Skip to content

JavaScript API

The routingcat package, which npx routingcat init installs, gives your Astro site:

  • routingcat/astro, the integration in astro.config, and the declarations;
  • routingcat/astro/components, <RoutingCat /> for your layout and <RoutingCatSlot> for your pages;
  • routingcat/tracker, routingcat, whose calls report conversions from your pages’ own scripts, and routingcatProperties for your PostHog init;
  • routingcat/vercel or routingcat/cloudflare, the entry that runs on your host: Vercel’s middleware, or the Worker on Cloudflare Workers;
  • routingcat/browser, for other frontends, such as an app on another of your domains, also as a script tag.

routingcat/astro

routingcat()

routingcat(options?: RoutingCatAstroOptions): AstroIntegration

The integration, the entry’s default export, in astro.config‘s integrations, as init adds it. At each astro build, it registers what routingcat/ declares, and gives your host’s entry the build’s values. Its middleware runs before yours, and hands a page rendered at each request the variants RoutingCat picked for its visitor. Under astro dev, ?rc= forces variants, and nothing is recorded. It runs on Vercel, with @astrojs/vercel or no adapter, and on Cloudflare Workers, with @astrojs/cloudflare, and matches your pages’ routes from your site’s root, so your site is served from /, with no base of its own; the build says when either is not so. From the example app, astro.config.mjs:

astro.config.mjs
// The example's Astro config (README.md): routingcat/astro's integration, and the host's adapter,
// Vercel's when Vercel builds it, Cloudflare's otherwise, whose Worker is the custom entrypoint
// src/worker.ts (wrangler.jsonc). /pricing renders at each request; every other page is
// prerendered. No session and no image binding, which the example needs not.
import cloudflare from '@astrojs/cloudflare'
import vercel from '@astrojs/vercel'
import { defineConfig } from 'astro/config'
import routingcat from 'routingcat/astro'
export default defineConfig({
integrations: [routingcat()],
adapter: process.env.VERCEL ? vercel() : cloudflare({ imageService: 'passthrough' }),
session: false,
})

Its options:

Option Type Default What it does
posthog string None The module whose default export is your site’s PostHog client, which RoutingCat registers the visitor id and the variants with, for a site whose client is not posthog-js’s own instance: a path from your project’s root, as './src/lib/posthog.ts'. Unset, posthog-js’s own instance, where your project depends on posthog-js; else window.posthog. See PostHog.

Astro.locals.routingcat.track()

Astro.locals.routingcat.track(name: string, options: TrackOptions & { readonly id: string }): Promise<void>

Reports an event, such as a conversion, from a page or an endpoint rendered at each request: RoutingCat’s middleware gives each request Astro.locals.routingcat, which an endpoint reads as locals.routingcat. Call it without await: it sends within the request’s lifetime, so a page is never slower, and never throws, so it never breaks a sign-up. A prerendered page renders once, for every visitor, and sends nothing. From the example app:

src/pages/api/waitlist.ts
import type { APIRoute } from 'astro'
export const prerender = false
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export const POST: APIRoute = async ({ request, locals }) => {
const body = (await request.json().catch(() => null)) as { email?: unknown } | null
const email = String(body?.email ?? '').trim()
if (!EMAIL.test(email)) {
return Response.json({ ok: false, message: 'Enter a valid email address.' }, { status: 400 })
}
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(email.toLowerCase()),
)
const id = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
locals.routingcat.track('waitlist', { id: `waitlist:${id}` })
return Response.json({ ok: true, message: 'You are on the list.' })
}
Option Type What it does
id string The dedup key: an event sent again with the same name and id counts once. Required on the server: the provider’s event id, or signup:<accountId>; a call without it is not sent, and says so once in your logs. Generated in the browser.
occurredAt Date | string | number When it happened: a Date, an RFC 3339 string, or a number of Unix seconds, or of milliseconds as Date.now() gives them; the call’s time when absent. The browser always uses the time the event arrives.
visitorId string The visitor, when the request is not theirs, as in a webhook: the rc_vid its checkout recorded. The request’s rc_vid cookie by default.
accountId string Your app’s id for the account, when you have it: a record id, never an email address.
value number A number. With currency, money in major units: 29.99 for $29.99. Without, a plain quantity.
currency string The ISO 4217 code, in either case.
properties object An object of at most 8,000 bytes of JSON, recorded with the event.
items LineItem[] Line items: { id, quantity?, unitValue }, in the event’s currency, quantity 1 when absent.

Astro.locals.routingcat.identify()

Astro.locals.routingcat.identify(accountId: string, options?: IdentifyOptions): Promise<void>

Links the visitor to your app’s account id, at sign-up and at every sign-in, so conversions on another device count. It never delays the response and never throws. Write await before it, as the examples do; RoutingCat joins a link and the events in whichever order they arrive. From the example app:

src/pages/api/signup.ts
import type { APIRoute } from 'astro'
export const prerender = false
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export const POST: APIRoute = async ({ request, locals }) => {
const body = (await request.json().catch(() => null)) as { email?: unknown } | null
const email = String(body?.email ?? '').trim()
if (!EMAIL.test(email)) {
return Response.json({ ok: false, message: 'Enter a valid email address.' }, { status: 400 })
}
const account = `acct_${crypto.randomUUID()}`
await locals.routingcat.identify(account)
return Response.json({ ok: true, account })
}
Option Type What it does
visitorId string The visitor to link, when the request is not theirs, as in a webhook. The request’s rc_vid cookie by default.

Astro.locals.routingcat.shown()

Astro.locals.routingcat.shown(page: P): Shown<P> | null

Which variant each slot of a page shows, by slot key, for server code that must know, such as an analytics call: Astro.locals.routingcat.shown(pricing), typed from the page’s declaration. On a page rendered at each request, it reads the variants RoutingCat picked for the visitor. On a prerendered page, or one a cache serves every visitor, it is null: every variant is in the page’s HTML, and the head snippet shows each visitor theirs in the browser. It is null for another page’s declaration too.

config(), page(), slot() and variant()

The declarations: see Config and declarations.

Also exported

  • DeclarationError, what the declarations throw for a declaration that breaks a rule.
  • The type RoutingCatAstroOptions, of the integration’s options.
  • The type RoutingCatLocals, of Astro.locals.routingcat, where RoutingCat’s middleware puts the calls above; Shown, what shown() returns; and TrackOptions and IdentifyOptions, of the calls’ options.
  • The types ConfigDeclaration, PageDeclaration, SlotDeclaration and VariantDeclaration.

routingcat/astro/components

<RoutingCat />

In your layout’s <head>, first after the charset and viewport tags. It writes the head snippet, which shows each visitor’s variants before the page paints, then RoutingCat’s script, which reports what each slot showed and how it was seen, and shows the next page’s variants when Astro’s client router swaps it in. It renders nothing else. From the example app, src/layouts/Base.astro:

---
// …
import { ClientRouter } from 'astro:transitions'
import { RoutingCat } from 'routingcat/astro/components'
interface Props {
title: string
}
const { title } = Astro.props
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<RoutingCat />
<title>{title}</title>
<ClientRouter />
…
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/pricing">Pricing</a>
<a href="/about">About</a>
</nav>
</header>
<slot />
</body>
</html>
Prop Type Default What it does
nonce string None Your page’s Content Security Policy nonce, where your policy allows scripts by nonce: the head snippet carries it, and so do the style rules it writes.

<RoutingCatSlot>

Renders one slot of a page, as its page is rendered. On a prerendered page, it writes every variant, the default shown and the others hidden, and the head snippet shows each visitor’s variant; on a page rendered at each request, it writes the visitor’s variant alone. Its child is a function, which receives the variant’s fields, then its key, and returns what to show.

Prop Type Default What it does
page PageDeclaration Required The page’s declaration, which the page imports from routingcat/.
name string Required The slot’s key, one the page declares. It is name, never slot, which Astro reads as a layout’s own named slot, so a slot placed directly in a layout stays in the page.
tag 'div' | 'span' div span inside a paragraph, or anywhere else only inline content can go, where a div would close the paragraph.
class string None Classes for the element that holds the slot: around its variants, or the visitor’s variant on a page rendered at each request.
id string None An id for that element.

From the example app, the prerendered src/pages/index.astro:

---
// `/`, prerendered: every variant in the page, the head snippet showing the host's selection.
import { RoutingCatSlot } from 'routingcat/astro/components'
import { home } from '../../routingcat/home'
import Base from '../layouts/Base.astro'
---
<Base title="RoutingCat">
<main>
<RoutingCatSlot page={home} name="hero">
{(fields: { headline: string }) => <h1>{fields.headline}</h1>}
</RoutingCatSlot>
<RoutingCatSlot page={home} name="subhead">
{(fields: { text: string }) => <p>{fields.text}</p>}
</RoutingCatSlot>
<a href="/pricing">See pricing</a>
</main>
</Base>

And src/pages/pricing.astro, rendered at each request, with a slot inside a sentence:

---
// `/pricing`, rendered at each request: the variants the host's decision selects, alone.
import { RoutingCatSlot } from 'routingcat/astro/components'
import { pricing } from '../../routingcat/pricing'
import Base from '../layouts/Base.astro'
export const prerender = false
---
<Base title="Pricing">
<main>
<h1>Pricing</h1>
<RoutingCatSlot page={pricing} name="lead">
{(fields: { lead: string }) => <p class="lead">{fields.lead}</p>}
</RoutingCatSlot>
<p>
Start your
<RoutingCatSlot page={pricing} name="trial" tag="span">
{(fields: { days: number }) => `${fields.days}-day`}
</RoutingCatSlot>
trial.
</p>
</main>
</Base>

The entry also exports the types of the components’ props, RoutingCatProps and RoutingCatSlotProps.

routingcat/tracker

routingcat.track() and routingcat.identify()

routingcat.track(name: string, options?: TrackOptions): Promise<void>
routingcat.identify(accountId: string, options?: IdentifyOptions): Promise<void>

The calls of your pages’ own scripts, which run in the browser, where there is no Astro.locals: import { routingcat } from 'routingcat/tracker' in a <script>. They report an event, such as a conversion, and link the visitor to your app’s account id, from the page, with your site’s public key, which the script of <RoutingCat /> brings to the page. Both never throw. An event’s time is when it arrives, and track’s options are:

Option Type Default What it does
id string None The dedup key: an event sent again with the same name and id counts once. Generated when absent.
visitorId string The rc_vid cookie’s The visitor.
accountId string None Your app’s id for the account, when you have it: a record id, never an email address.
value number None A number. With currency, money in major units: 29.99 for $29.99. Without, a plain quantity.
currency string None The ISO 4217 code, in either case.
properties object None An object of at most 8,000 bytes of JSON, recorded with the event.
items LineItem[] None Line items: { id, quantity?, unitValue }, in the event’s currency, quantity 1 when absent.
routingcat.link(url: string | URL): string

A link to another of your domains, carrying the visitor id, so the visit continues there: the script on that domain adopts the id. Add the domain with npx routingcat domains add.

routingcatProperties

PostHog’s before_send hook, for your site’s PostHog init in a <script>: import { routingcatProperties } from 'routingcat/tracker', then before_send: routingcatProperties among posthog.init’s options. It puts the visitor id and the page’s variants on every PostHog event as it leaves, a page view’s first $pageview included.

routingcat/vercel

middleware()

middleware(handler: MiddlewareHandler | null, options: MiddlewareOptions): (request: Request, context?: MiddlewareContext) => Promise<Response>

Vercel’s Routing Middleware, your root middleware.ts’s default export, on Node.js, as init writes it: middleware(null, { … }). It picks each visitor’s variants before Vercel serves the page, from its cache or not, and passes /_rc on to RoutingCat, so that the browser’s reports reach it through your own domain. Around a middleware of your own, middleware(yourMiddleware, { …, paths }), your code first, as in worker(app, …): your middleware runs first on your own paths.

From the example app:

middleware.ts
// Vercel's root middleware for an Astro site, the file `init` is to write (integrations/a_architecture
// §4.4, §5.5): RoutingCat decides every page before Vercel's cache, on Node.js, prerendered pages'
// hits included, with the build, which `astro build` writes to .routingcat/ before Vercel builds
// this file. Astro's own middleware keeps the adapter's default mode: deployed at the edge,
// `middlewareMode: 'edge'`, it serves the pages rendered at request time past this file, which then
// routes the prerendered pages alone, and the build warns with D117.
import { ASTRO_DESCRIPTOR, middleware } from 'routingcat/vercel'
import build from './.routingcat/values.mjs'
export const config = {
runtime: 'nodejs',
matcher: [
'/((?!_astro/|_image(?:/|$)|_server-islands/|_actions/|_render(?:/|$)|_isr(?:/|$)|_rc/|api/|.*\\.[a-z0-9]+$).*)',
'/_rc/:path*',
], // RoutingCat: Astro's pages, and /_rc
}
export default middleware(null, {
framework: ASTRO_DESCRIPTOR,
matcher: config.matcher,
build,
})
Option Type Default What it does
framework FrameworkDescriptor Required What renders your pages, as init wrote it: its descriptor, whose contents are RoutingCat’s own.
matcher string | string[] Required config.matcher, as it is: the paths RoutingCat runs on, which the middleware reads as Vercel does, with the test of what is one of your pages. Around a middleware of your own, RoutingCat’s entries of it alone.
build RoutingCatBuild Required .routingcat/values.mjs’s default export, the build, which your build writes and the package types: its values, and the snapshot, the routing data a new instance decides with until its first refresh and while RoutingCat can’t be reached. Without values, pages serve their defaults and nothing is recorded.
paths string[] None Your own middleware’s matcher entries, unchanged, as data: your handler runs only on those paths.

Also exported

  • ASTRO_DESCRIPTOR, the framework that renders an Astro site’s pages.
  • The types MiddlewareOptions, MiddlewareHandler, MiddlewareContext, RoutingCatBuild and FrameworkDescriptor.

routingcat/cloudflare

worker()

worker<Env>(app: CloudflareWorker<Env> | null, options: WorkerOptions): CloudflareWorker<Env> & { fetch(request: Request, env: Env, ctx: WorkerContext): Promise<Response> }

The Worker that picks each visitor’s variants before the page is served, your Worker’s default export, as init writes it, and that passes /_rc on to RoutingCat.

It stands in front of Astro’s custom entrypoint, which Astro’s Cloudflare adapter builds: worker(app, { mode: 'wrap', … }). From the example app:

src/worker.ts
// The Worker on Cloudflare Workers for an Astro site, the file `init` writes
// (integrations/a_architecture §4.5, §5.5): Astro's custom entrypoint, cf(), astro() and
// finalize(), wrapped in RoutingCat's, which decides each page first. The build comes from
// routingcat/vite's module, which `astro build` builds into the Worker with the bundle compiled from
// the build's push; under `astro dev`, which runs this Worker, the build says it is a development
// server's, and `?rc=` forces variants. wrangler.jsonc names this file.
import build from 'virtual:routingcat/values'
import { cf, finalize } from '@astrojs/cloudflare/fetch'
import { astro, FetchState } from 'astro/fetch'
import { ASTRO_DESCRIPTOR, type WorkerContext, worker } from 'routingcat/cloudflare'
const app = {
// The Worker's bindings, as Astro's adapter reads them: ASSETS, CF_VERSION_METADATA.
async fetch(request: Request, env: unknown, ctx: WorkerContext) {
const state = new FetchState(request)
const asset = await cf(state, env, ctx)
if (asset) return asset
return finalize(state, await astro(state))
},
}
export default worker(app, {
mode: 'wrap',
framework: ASTRO_DESCRIPTOR,
// The same literal as run_worker_first in the Wrangler configuration: Astro's page test leaves out
// the server's own paths it runs first on.
matcher: ['/*', '!/_astro/*'],
build,
})
Option Type Default What it does
mode 'wrap' Required In front of Astro’s own Worker, the first argument.
framework FrameworkDescriptor Required What renders your pages, as init wrote it: its descriptor, whose contents are RoutingCat’s own.
matcher string | string[] Required The paths RoutingCat runs on: the same patterns as run_worker_first in your Wrangler configuration, which the Worker reads as Cloudflare does, with the test of what is one of your pages, which those patterns can’t say.
build RoutingCatBuild Required virtual:routingcat/values’s default export, the build, which your build writes and the package types: its values, and the snapshot, the routing data a new instance decides with until its first refresh and while RoutingCat can’t be reached. Without values, pages serve their defaults and nothing is recorded.

Also exported

  • ASTRO_DESCRIPTOR, the framework that renders an Astro site’s pages.
  • The types WorkerOptions, CloudflareOptions, CloudflareWorker, WorkerContext, RoutingCatBuild and FrameworkDescriptor.

routingcat/browser

For a frontend that is not your Astro site, such as an app on another domain: it sends events with the site’s public key, through your Astro site’s /_rc path.

Its calls are its routingcat object’s, import { routingcat } from 'routingcat/browser', the same object its script tag puts on the page’s window.routingcat.

routingcat.init()

routingcat.init(key: string, options?: InitOptions | null): void

Sets the site’s public key, from routingcat.config.ts, and where to send. Calls before it send nothing.

Option Type What it does
env 'production' | 'preview' production, by default, or preview.
collector string The site whose /_rc path to send through, such as https://lumen.so for an app on another domain. The page’s own by default.

routingcat.track() and routingcat.identify()

routingcat.track(name: string, options?: TrackOptions): Promise<void>
routingcat.identify(accountId: string, options?: IdentifyOptions): Promise<void>

As your own app’s routingcat.track() and routingcat.identify() do, from the browser: an event’s id is generated where you give none, and its time is when it arrives. An option set to null reads as absent.

routingcat.link(url: string | URL): string

A link to another of your domains, carrying the visitor id, so the visit continues there: the script on that domain adopts the id. Add the domain with npx routingcat domains add.

The entry also exports the types InitOptions, TrackOptions, IdentifyOptions and LineItem.

routingcatProperties

PostHog’s before_send hook, as routingcat/tracker’s: in your PostHog init, before_send: routingcatProperties puts the visitor id and the page’s variants on every PostHog event.

The script tag

The same entry as a script, at a URL that never changes for a version, with its integrity hash:

<script src="https://cdn.routingcat.com/browser/1.0.3.js" integrity="sha384-ZnRbP0Groc12kRo2DHm+EpSi/r27mWVkwT7ehqGhXHZY3NjqdZvTJHtolZL8IbFW" crossorigin="anonymous" data-key="pk_…"></script>

The copy of this page in node_modules/routingcat/docs/ gives the installed version’s hash. The script reads data-key, the site’s public key; data-env, preview for a preview build; and data-collector, as routingcat.init’s collector. It puts its calls on the page’s window.routingcat: routingcat.track, routingcat.identify, routingcat.link, routingcat.init and routingcat.routingcatProperties. Or copy node_modules/routingcat/dist/browser.global.js into your site and load it from there.