Skip to content

Build a page

A routed page needs a declaration in routingcat/, which says what can vary, and a page that renders what RoutingCat picked for each visitor. This guide covers both, for static and server-rendered pages, and the three rules that keep what visitors see measurable. To choose a page’s shape first, see Pages, variants and versions.

Declare the page

Each page is one file in routingcat/ that exports one page(): its key, its routes and its slots. Each slot() names its default and its variants, and each variant() holds what it shows. This landing page varies its headline and the line under it, as two slots:

routingcat/home.ts
import { page, slot, variant } from 'routingcat/next'
export const home = page('home', {
routes: ['/'],
slots: {
hero: slot({
default: 'current',
variants: {
current: variant({ fields: { headline: 'Route every visitor to what converts' } }),
learns: variant({
fields: { headline: 'Your pages learn what converts each visitor' },
description: 'Lead with learning',
}),
shows: variant({
fields: { headline: 'Show each visitor the page that converts them' },
description: 'Lead with what each visitor sees',
}),
tries: variant({
fields: { headline: 'Try your ideas on real visitors, keep what works' },
description: 'Lead with trying ideas',
}),
},
}),
subhead: slot({
default: 'current',
variants: {
current: variant({
fields: {
text: 'Each visitor sees the version of the page most likely to convert them, and you see which version works for whom.',
},
}),
short: variant({
fields: { text: 'Every visitor gets the page that works for them. You see what works.' },
description: 'The same promise, shorter',
}),
proof: variant({
fields: {
text: 'Every change is measured on your own visitors, so you see the proof before you keep it.',
},
description: 'Lead with measured proof',
}),
plain: variant({
fields: { text: 'RoutingCat picks the best version of your page for each visitor.' },
description: 'One plain sentence',
}),
},
}),
},
})
  • Keys are lowercase letters, digits, - and _, up to 40 characters. Addresses are built from them: home, home/hero, home/hero:learns.
  • A new variant takes a description, saying what it tries, as learns does. show prints it with the variant’s history, for you and the next agent.
  • Routes use Next.js’s notation, such as /pricing or /blog/[slug]. A path belongs to one page.
  • The default is what your site shows today, kept under its own key. The control always sees it.
  • Slots vary independently, so every combination must render: here 4 headlines and 4 lines make 16 combinations.
  • A page can have its own objective, with objective in its page(), in the same forms as the site’s in routingcat.config.ts.
  • Limits: at most 20 slots and 100 live variants per page; a prerendered page warns above 100 combinations and is rejected above 1,000. See Limits and defaults. On Hobby, your sites serve at most 10 live variants together, the defaults not counted, and hold the rest.

A variant is made of fields or is a component. Fields are plain values, such as text, numbers or an imported image, and the page renders them. A component variant is variant({ component, content }), or the component alone; its content declares what it displays, the way fields do, so that its versions and their differences can be read.

RoutingCat keeps a version of what you declare: a variant’s fields, or a component variant’s own code and its content, with the images either uses.

  • An image imported from your repository is kept by its bytes; one given by URL, by its URL.
  • A data file a component reads, such as a JSON file of headlines, belongs in its content. Otherwise editing the file makes no new version, and the build log warns.
  • Content fetched at run time isn’t captured, so its changes leave no version.
  • A variant that is a whole page declares its headline and key copy in content, since its rendered text isn’t captured.

Pages, variants and versions says what makes a new version.

Render a static page

A static page keeps its file, passes its params to routingcat.decide, and renders each slot that it returns:

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>
)
}

Beside it, a wrapper route serves the prerendered combinations. RoutingCat’s proxy rewrites each request to the combination it picked, without changing the URL, and params carries that combination to the page:

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)
  • The wrapper’s path is routingcat/[rc]/page.tsx in the page’s own folder: app/pricing/routingcat/[rc]/page.tsx for app/pricing/page.tsx. plan prints the file when it is missing.
  • It repeats the page’s route settings as literals, such as export const revalidate = 3600, because Next.js ignores settings a file re-exports. plan checks that they match.
  • It never sets dynamicParams = false, which would turn every variant but the default into a 404.
  • Every combination is built at deploy, of the variants the page serves, so each visitor’s page comes from the cache. A paused or retired variant, or one your plan holds, is left out, and should it serve again before the next deploy, its combinations render on their first request. A page builds 1,000 copies at most, those of its params included, and its defaults alone past them. The build log says how many copies each page built.
  • A page whose routes take params, such as /blog/[slug], passes its own to the wrapper: routingcat.prerender(blog, blogParams), where blogParams is the page’s generateStaticParams, imported from '../../page', which the wrapper’s own would otherwise replace, or a list of its params. Every combination is then built at deploy for each of them while the page’s copies stay within 1,000, and each one’s defaults alone past them. plan prints the wrapper this way for a page that exports generateStaticParams; without its params, no page of the route is built at deploy, and the build log says so.
  • The build fails when the two disagree: params without a wrapper, or a wrapper without params. Otherwise the page would show its defaults while RoutingCat recorded other variants.

Without the proxy, the page’s own route still serves its defaults.

Render a server-rendered page

A page that renders on each request calls routingcat.decide(page), with no params and no wrapper. It reads the variants the proxy picked for that request:

routingcat/pricing.ts
import { page, slot, variant } from 'routingcat/next'
export const pricing = page('pricing', {
routes: ['/pricing'],
slots: {
lead: slot({
default: 'growth',
variants: {
growth: variant({ fields: { lead: 'Free while you grow, then by billed visitors' } }),
start: variant({
fields: { lead: 'Start free; pay only for the visitors you bill' },
description: 'Lead with starting free',
}),
},
}),
// The order of the sections under the lead, by the names app/pricing/page.tsx gives them.
sections: slot({
default: 'current',
variants: {
current: variant({ fields: { order: ['plans', 'contact', 'waitlist'] } }),
waitlist: variant({
fields: { order: ['waitlist', 'plans', 'contact'] },
description: 'The waitlist before the plans',
}),
},
}),
},
})
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>
)
}

routingcat.decide(page) reads the request, so the page renders on each request. Choose it for a page that does anyway, as this one does to show prices in the visitor’s currency; a page that can be prerendered keeps its variants cached with a wrapper. When the proxy didn’t pick variants for the request, routingcat.decide returns the defaults, with a warning in development, never an error.

The sections slot orders the page’s sections: each variant holds an order of names, and the page renders its own sections in that order, so the sections stay in the page’s code and only their order varies.

Keep what visitors see measurable

  • Render each slot through its component: <x.hero>{(f) => …}</x.hero> for fields, or <x.cta /> for a component variant, which passes on its props. The component marks the slot in the page, so the browser can report what each visitor saw. x.hero.fields reads the same content, but a slot read only that way carries no mark, and what visitors saw there goes unreported.
  • Await routingcat.decide at the top of the page, outside any <Suspense>, so the variants render with the page itself rather than stream in after it. With Cache Components, a server-rendered page is the exception: its routingcat.decide(page) reads the request, which Next.js requires inside <Suspense>, so give the page a loading.tsx, as the example’s pricing page has, 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>.
  • Inside a paragraph, give the slot tag="span", as in <p>Start your <x.word tag="span">{(f) => f.word}</x.word> trial</p>: the slot is marked by a div, which would close the paragraph and break the page’s hydration. Anywhere else only inline content can go takes it too.
  • The server code runs on Node.js. A route on the Edge runtime, export const runtime = "edge", cannot call routingcat.decide, routingcat.track or routingcat.identify.
  • In a layout above a static routed page, read usePathname() only after the component mounts, such as in an effect that sets the active link. On a prerendered page reached through a rewrite, as RoutingCat’s static pages are, Next.js can report a hydration mismatch otherwise. plan points out a layout above a static routed page that reads the pathname during render, and leaves one that reads it only in an effect alone.

Next