Skip to content

JavaScript API

The routingcat package, which npx routingcat init installs, has three entries:

  • routingcat/next, for your Next.js app: pages, layouts, route handlers, Server Functions and client components, and next.config.
  • routingcat/next/proxy, for proxy.ts alone, so your pages never carry the proxy’s code.
  • routingcat/browser, for other frontends, also as a script tag.

routingcat/next

Its server code runs on Node.js, where Next.js 16 runs proxy.ts: a route on the Edge runtime, export const runtime = "edge", cannot use it.

routingcat.decide()

routingcat.decide<P extends PageDeclaration>(page: P, options?: { params: Promise<Params> | Params }): Promise<Decided<P>>

The variants the proxy chose for this request, by slot key, in a server component. A static page calls routingcat.decide(page, { params }), through its wrapper route: it reads the params alone, so await it at the top of the page, outside any <Suspense>, and its variants render with the page itself. A server-rendered page calls routingcat.decide(page), which reads the request: with Cache Components, Next.js requires that inside <Suspense>, so give the page a loading.tsx, as the example app does, or wrap the part that calls it in <Suspense>, and its variants stream in after the page’s shell; without either, next build stops, saying the route read runtime data outside <Suspense>. Without Cache Components, await it at the top of the page too. From the example app, with its loading.tsx beside it:

app/pricing/page.tsx
import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { Fragment, type ReactNode } from 'react'
import { routingcat } from 'routingcat/next'
import { TalkToUs } from '@/components/talk-to-us'
import { WaitlistForm } from '@/components/waitlist-form'
import { pricing } from '@/routingcat/pricing'
import { startCheckout } from './checkout'
import { monthlyPrice } from './price'
import { startWhopCheckout } from './whop'
export const metadata: Metadata = { title: 'RoutingCat example: pricing' }
/**
* The pricing page: rendered per request, in the visitor's currency, its lead line and the order of
* its sections routed.
*/
export default async function PricingPage({
searchParams,
}: {
searchParams: Promise<{ checkout?: string }>
}) {
const country = (await headers()).get('x-vercel-ip-country')
const { checkout } = await searchParams
const x = await routingcat.decide(pricing)
// The sections, by the names the `sections` slot's variants put in order.
const sections: Record<string, ReactNode> = {
plans: (
<>
<p>From {monthlyPrice(country)} a month</p>
{checkout === 'done' ? (
<p>Thank you: your first month is paid.</p>
) : (
<form action={startCheckout}>
<button type="submit">Pay the first month</button>
<button type="submit" formAction={startWhopCheckout}>
Pay with Whop
</button>
</form>
)}
</>
),
contact: <TalkToUs />,
waitlist: <WaitlistForm label="Join the waitlist" />,
}
return (
<main>
<h1>Pricing</h1>
<x.lead>{(f) => <p>{f.lead}</p>}</x.lead>
<x.sections>
{(f) => f.order.map((s) => <Fragment key={s}>{sections[s]}</Fragment>)}
</x.sections>
</main>
)
}

Each slot’s result renders its variant: <x.lead /> renders a component variant with the props you give, and a variant made of fields with a function child, <x.lead>{(f) => <p>{f.lead}</p>}</x.lead>. Render every slot this way, so the browser reports what it showed. The slot is marked by a div; inside a paragraph, or anywhere else only inline content can go, give it tag="span", <x.lead tag="span">, since a div there would close the paragraph and break the page’s hydration. A component variant never receives tag. The result also has:

Member Type What it does
variant string The variant’s key.
fields object The variant’s fields, or a component variant’s declared content.
hash string | null The variant’s content hash; null without one.

routingcat.prerender()

routingcat.prerender(page: PageDeclaration): () => Promise<{ rc: string }[]>
routingcat.prerender<P extends RouteParams, A = unknown>(page: PageDeclaration, pageParams: P[] | ((props: A) => P[] | Promise<P[]>)): (props: A) => Promise<(P & { rc: string })[]>

generateStaticParams for a static page’s wrapper route, which serves the page’s combinations: every combination of the variants the page serves is built at deploy, so each visitor’s page comes from the cache, whichever variants they get. A variant that serves no visitor when the deploy goes live, paused, retired or held by your plan, is left out, and should it serve again before the next deploy, its combinations render on their first request and stay cached for the deployment. A page builds 1,000 copies at most, those of its params included: past them, its defaults alone. The build log says how many copies each page built. The page itself takes params and passes them to routingcat.decide:

app/page.tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import { routingcat } from 'routingcat/next'
import { WaitlistForm } from '@/components/waitlist-form'
import { home } from '@/routingcat/home'
export const metadata: Metadata = {
alternates: { canonical: '/' },
}
/** The landing page: static, prerendered at build, its headline and the line under it routed. */
export default async function HomePage({ params }: { params: Promise<{ rc?: string }> }) {
const x = await routingcat.decide(home, { params })
return (
<main>
<x.hero>{(f) => <h1>{f.headline}</h1>}</x.hero>
<x.subhead>{(f) => <p>{f.text}</p>}</x.subhead>
<WaitlistForm label="Join the waitlist" />
<p>
<Link href="/pricing">See pricing</Link>
</p>
</main>
)
}
app/routingcat/[rc]/page.tsx
// The wrapper route of app/page.tsx: it serves the home page's prerendered combinations.
import { routingcat } from 'routingcat/next'
import { home } from '@/routingcat/home'
export * from '../../page'
export { default } from '../../page'
export const generateStaticParams = routingcat.prerender(home)

A page whose routes take params, as /blog/[slug], passes its own params too, routingcat.prerender(blog, blogParams): the page’s own generateStaticParams, imported into the wrapper route as import { generateStaticParams as blogParams } from '../../page', since the wrapper’s own would otherwise replace it, or a list of its params. Each of them is built at deploy with every combination while the page’s copies stay within 1,000, those of each set of params a parent layout gives counted together, and with its defaults alone past them. npx routingcat plan prints the wrapper route in that form for a page that exports generateStaticParams. Without them, none of the page’s combinations is built at deploy, and the build log says so.

routingcat.track()

routingcat.track(name: string, options: TrackOptions): Promise<void>

Reports an event, such as a conversion. On the server, call it without await: it sends after the response, so a page is never slower, and never throws, so it never breaks a sign-up. In client components, the same import sends from the browser. Its id is required wherever you call it, since a server event needs its dedup key; in a client component, a value of your own, such as a hash of what the event concerns, also keeps a double submit from counting twice.

Option Type What it does
id, required string The dedup key: an event sent again with the same name and id counts once. The provider’s event id, or signup:<accountId>. On the server, a call without it is not sent, and says so once in your logs.
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.

An option set to null reads as absent, as a payment mapping’s unknown fields are. Before it sends, routingcat.track checks each field as RoutingCat’s events API does: a name it refuses, or an id over 256 characters, is not sent; an invalid optional field is left out and the event sent; a visitorId that is not an rc_vid value gives way to the request’s visitor. Each of these, and each event RoutingCat’s API refuses, as one more than five minutes ahead, is said once in your logs.

routingcat.identify()

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 sends after the response, beside the events the request tracks, and never throws: RoutingCat joins a link and its events in whatever order they arrive, so it needs no await.

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.

routingcat.track and routingcat.identify send from the App Router’s server code, its Server Functions, route handlers and server components, and from client components in the browser. In a Pages Router API route, getServerSideProps or a script, they send nothing and say so once in your logs: post the events there to RoutingCat’s HTTP events API, with the server events key.

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. It runs in client components, once the component has mounted or in the link’s click handler: during the render, the server’s returns the link unchanged and hydration keeps it, so a link made while rendering never carries the id. In server code it throws, since the visitor id is the browser’s.

<RoutingCat />

In each root layout, as the last child of <body>. It reports what each slot showed and how it was seen, and shows the preview badge on a preview link. It renders nothing else.

withRoutingCat()

withRoutingCat(nextConfig?: NextConfigInput): NextConfigFunction

Wraps your next.config export, outermost but for Sentry’s withSentryConfig, which stays outside it. It adds the /_rc rewrite, which carries the browser’s reports through your own domain, and the build step, which registers what your code declares at each deploy. An app built without it builds all the same, but routes nothing and records nothing, and says so once in its logs. From the example app, its own settings left out:

next.config.ts
import type { NextConfig } from 'next'
import { withRoutingCat } from 'routingcat/next'
const nextConfig: NextConfig = {
// …
}
export default withRoutingCat(nextConfig)

routingcatProperties

PostHog’s before_send hook, for your app’s PostHog init: it puts the visitor id and the page’s variants on every PostHog event. From the example app:

instrumentation-client.ts
// PostHog, installed as its guide for the Next.js App Router does: posthog-js's own instance,
// initialized before hydration. withRoutingCat() finds posthog-js among the dependencies and hands this
// instance to <RoutingCat />, which registers the visitor id and the live variants as super properties
// at each page view's render beacon. PostHog captures a page view's $pageview before that, so
// `before_send` takes routingcatProperties, the line the skill adds: it puts them on every event as it
// leaves. Without a project key, as in development without one, PostHog stays off.
import posthog from 'posthog-js'
import { routingcatProperties } from 'routingcat/next'
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY
if (key) {
posthog.init(key, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
defaults: '2026-05-30',
before_send: routingcatProperties,
})
}

fromMinorUnits()

fromMinorUnits(amount: number, currency: string): number

An amount in a currency’s minor units, as Stripe gives it, in the major units value takes, the currency in either case:

Call Value
fromMinorUnits(2999, 'usd') 29.99
fromMinorUnits(1500, 'jpy') 1500
fromMinorUnits(5125, 'kwd') 5.125

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 types ConfigDeclaration, PageDeclaration, SlotDeclaration, VariantDeclaration, Decided, SlotResult, TrackOptions and IdentifyOptions.

routingcat/next/proxy

proxy()

proxy(): RoutingCatProxy
proxy(handler: ProxyHandler, options: ProxyOptions): RoutingCatProxy

Picks each visitor’s variants before the page renders, in proxy.ts. Alone, export default proxy(); around a proxy of your own, export default proxy(handler, { paths }), where paths is required: your own matcher entries, unchanged, which also come before RoutingCat’s entry in config.matcher. Export no function of your own named proxy from the file: Next.js would run it in place of RoutingCat’s, which npx routingcat doctor points out. Next.js reads config.matcher as a literal, so RoutingCat’s entry is written out in the file. From the example app:

proxy.ts
import { proxy } from 'routingcat/next/proxy'
export default proxy()
export const config = {
matcher: ['/((?!_next/|_rc/|api/|.*\\.(?!rsc$)[a-z0-9]+$).*)'], // RoutingCat: pages and .rsc payloads, prefetches included
}
Option Type What it does
paths, required string[] Your own matcher entries, unchanged, as data: your handler runs only on those paths.

Both forms return RoutingCatProxy, the function Next.js calls with each request and its event. The entry also exports the type ProxyHandler, of your own handler.

routingcat/browser

For a frontend that is not your Next.js app, such as an app on another domain: it sends events with the site’s public key, through your Next.js 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/next’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.