The proxy
proxy.ts runs in Vercel Routing Middleware before the page renders and decides which variant each visitor gets.
GET /pricing
hash(vid) vs cached weights
HTML of variant b
exposure, touch, conversion
One decision
- Read the visitor id from the
ccat_vidcookie, or create one. - Find the running experiments whose page matches the path (
/pricing, or/blog/*for a prefix). - Pick the weights for the visitor's segment if the router has learned one (for example
device:mobile), else the global weights. - Hash
vid:experimentwith FNV-1a into a number between 0 and 1 and walk the cumulative weights. Same visitor, same input, same answer. - Rewrite to the variant, set the cookies, add
x-ccat-decision-ms, and post the events after the response withwaitUntil.
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_vid | Random visitor id, 1 year, HttpOnly, first party. |
ccat_a | Assignments (pricing:b), 90 days, so a returning visitor sees the same variant. |
ccat_s | Session marker, 30 minutes, used for last-touch attribution. |
ccat_optout | Set 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.