The proxy

proxy.ts runs in Vercel Routing Middleware before the page renders and decides which variant each visitor gets.

visitor
GET /pricing
proxy.ts
hash(vid) vs cached weights
rewrite
/_v/b/pricing
HTML of variant b
after the response (waitUntil)
events
ConversionCat API
exposure, touch, conversion
every 30 s
weights back to the proxy
The decision happens before render. Network calls to our API never sit in front of the visitor, except a 600 ms cap on a cold start.

One decision

  1. Read the visitor id from the ccat_vid cookie, or create one.
  2. Find the running experiments whose page matches the path (/pricing, or /blog/* for a prefix).
  3. Pick the weights for the visitor's segment if the router has learned one (for example device:mobile), else the global weights.
  4. Hash vid:experiment with FNV-1a into a number between 0 and 1 and walk the cumulative weights. Same visitor, same input, same answer.
  5. Rewrite to the variant, set the cookies, add x-ccat-decision-ms, and post the events after the response with waitUntil.

The weights table is cached in the proxy and refreshed in the background every 30 seconds, so a decision is a few string operations. Measured locally: 0.25 to 0.8 ms per request, cold start excluded.

Cookies

ccat_vidRandom visitor id, 1 year, HttpOnly, first party.
ccat_aAssignments (pricing:b), 90 days, so a returning visitor sees the same variant.
ccat_sSession marker, 30 minutes, used for last-touch attribution.
ccat_optoutSet by your cookie banner when a visitor refuses: they see the control and nothing is recorded. A Sec-GPC: 1 header does the same.

Known bots and crawlers (by user agent) get a decision but no cookie and no event, so they do not dilute results.

Rewrite or header

Rewrite (default): variants other than the control are served from /_v/<variant><page> under the original URL. Direct visits to /_v/ are redirected to the page. Header: the page stays one file and reads the variant:

import { headers } from "next/headers";

const v = (await headers()).get("x-ccat-hero") ?? "a";

Our own home page runs in header mode: its headline is a live three-variant test, and the numbers are on /live.

When the API is down

On a cold start the proxy waits at most 600 ms for the first weights table, then serves the control while the table loads. If the API cannot be reached later, the last table keeps routing and the proxy retries every few seconds. Visitors are never blocked.