Configuration and environment variables
#evolve.config.ts
import { defineConfig } from "next-evolve/config";
export default defineConfig({
routes: {
"/": { mode: "static" },
"/pricing": { mode: "static" },
"/features": { mode: "dynamic" },
},
// everything below is optional; these are the defaults
store: "auto",
sync: { pollSeconds: 60 }, // 5 in development
database: { urlEnv: "EVOLVE_DATABASE_URL" },
aggregate: { maxMs: 25_000, statementTimeoutMs: 20_000, mode: "push" }, // mode since 0.7
retentionDays: undefined, // keep everything; or e.g. 180 (since 0.7)
cookies: { maxAgeDays: 365, domain: undefined },
consent: undefined, // (req: Request) => boolean
bots: { extraPatterns: [] },
cdnRouting: false, // or true, or { maxAgeSeconds: 60 } (since 0.6)
geoHeader: "x-vercel-ip-country", // country for UTM rules (since 0.8)
debug: process.env.NODE_ENV !== "production",
});
| Option | |
|---|---|
routes |
Pages Evolve may test, by exact path or (since 0.8) a pattern: /lp/:slug (one segment), /docs/:path* (zero or more, last segment only), /shop/:rest+ (one or more), and (since 0.9) a regex-constrained param /:name([A-Z][A-Za-z]*-[A-Za-z-]+) (one segment; the regex must match the whole raw segment; ≤ 200 characters; no capturing groups, backreferences, lookarounds, anchors, nested quantifiers or | inside a repeated group, so matching stays linear-time). No [params], can't start with /ev_. An exact path wins, then constrained patterns, then unconstrained ones, then the most specific pattern (fewest params, longest static prefix). A constrained route's folder is app/[evolve]/[name]; the proxy checks the constraint. Write the key as-is into the proxy matcher. mode: "static" (the page lives under app/[evolve]/, e.g. app/[evolve]/lp/[slug]/page.tsx, cached per variant) or "dynamic" (the page stays put, rendered per request). Pattern routes never take the cdnRouting fast path. Add a route here and to the proxy matcher; doctor checks both |
store |
"auto" uses Vercel Global Config when its env vars are set, else memory. "memory" or "global-config" force one |
sync.pollSeconds |
How often each instance checks for a new bundle (± 10% jitter) |
database.urlEnv |
Which env var holds the database URL |
aggregate.maxMs |
Time budget for one aggregate request; past it the response is partial and the cloud asks again |
aggregate.statementTimeoutMs |
Postgres statement_timeout per aggregation query |
aggregate.mode |
"push" (default): the cloud calls POST /api/evolve/aggregate. "pull" (since 0.7): the app polls the cloud every 60 s and posts results back, for servers the cloud can't reach. Pull needs a long-running Node server (next start, Docker) and registerEvolve(). See Going to production |
retentionDays |
Since 0.7. Delete local rows older than this many days (30 to 3650), once a day, from inside aggregate requests. Unset (default): nothing is deleted. See Retention |
cookies.maxAgeDays |
Lifetime of evolve_id and evolve_a |
cookies.domain |
Set to .example.com to share assignments across subdomains |
consent |
Return false to serve control and set no cookies (e.g. before cookie consent) |
bots.extraPatterns |
Extra user-agent patterns (case-insensitive regular expressions) treated as bots |
cdnRouting |
Opt-in (since 0.6), static-mode routes only: repeat views within maxAgeSeconds (default: sync.pollSeconds) are served from the CDN without running the proxy. A pause or rollback can then take up to maxAgeSeconds to reach a returning visitor. Needs evolveRewrites() in next.config and the evolveMatcher() literal in proxy.ts. See CDN routing |
geoHeader |
Since 0.8. Request header holding the visitor's ISO-3166 alpha-2 country, read only by UTM rules on country. Default x-vercel-ip-country; cf-ipcountry on Cloudflare. See UTM rules |
debug |
Extra logging |
#Environment variables
All server-only.
| Var | Needed | |
|---|---|---|
EVOLVE_SECRET_KEY |
yes (runtime and build) | Site key evk_live_<site>_<secret>. Authenticates calls to the cloud |
EVOLVE_WEBHOOK_SECRET |
yes | whsec_…. Verifies the cloud's calls to your app and signs aggregate responses |
EVOLVE_DATABASE_URL |
to measure | Your Postgres. Without it variants serve and nothing is recorded. Use the pooled URL on serverless. Never sent anywhere |
EVOLVE_TRACK_SECRET |
optional | Bearer secret for POST /api/evolve/track; the route is off (404) without it |
EVOLVE_APP_URL |
off Vercel | Your public base URL, sent with the manifest so the cloud can reach the sync and aggregate routes. On Vercel production it defaults to https://$VERCEL_PROJECT_PRODUCTION_URL |
EVOLVE_CACHE_DIR |
optional | Disk cache directory for the bundle on long-running Node (default os.tmpdir()) |
EVOLVE_CLOUD_URL |
no | Defaults to https://api.evolveab.com |
EVOLVE_GLOBAL_CONFIG |
optional (Vercel) | Global Config connection string (read) |
EVOLVE_GLOBAL_CONFIG_ID |
optional (Vercel) | Store id ecfg_… (writes) |
EVOLVE_VERCEL_API_TOKEN |
optional (Vercel) | Vercel token that can write the store |
EVOLVE_VERCEL_TEAM_ID |
optional (Vercel) | For team-owned stores |
init reuses DATABASE_URL or POSTGRES_URL if you agree (it writes the value into EVOLVE_DATABASE_URL).
Test-only variables (EVOLVE_UNSAFE_TEST_PUBLIC_KEY, EVOLVE_UNSAFE_ALLOW_TEST_KEY) exist for the SDK's own test suite. Never set them in production: they let a test key sign bundles.
#Cookies
| Cookie | Value | Set when |
|---|---|---|
evolve_id |
Random visitor id | First visit to a tested route with a running experiment |
evolve_a |
The visitor's assignments (experiment:variant, up to 20) |
Same, and when an assignment changes |
evolve_pv |
A signed preview token | Opening a dashboard preview link (session cookie) |
evolve_r |
UTM rules matched this session (experiment:rule, up to 20), session cookie |
Since 0.8: when a UTM rule matches |
evolve_r_<hash> |
The variant code for one route (ev_…), Max-Age = cdnRouting.maxAgeSeconds |
Only with cdnRouting: when the proxy routes a visitor on a static-mode route with a running experiment |
All are HttpOnly, SameSite=Lax, Path=/, and Secure over https. None is set for bots, before consent, or without a valid bundle.
#CDN routing (cdnRouting)
On Vercel, running any proxy costs about 18–23 ms of TTFB per request, even one that does nothing: it's the platform invoking a function before the cache lookup, not Evolve's code (0.1–0.5 ms). cdnRouting lets repeat views skip the proxy:
- The first view runs the proxy as usual. It also sets
evolve_r_<hash>(one per route) to the variant code it served, formaxAgeSeconds. - While that cookie lives, a
next.configrewrite sends the request straight to the cached variant page, and the proxy matcher skips it. Measured on Vercel: repeat views at the no-proxy TTFB (+0 ms), first views about +20 ms (unchanged).
The trade-off: staleness. A returning visitor who has the cookie keeps getting the variant they were routed to until it expires, even if the experiment was paused, completed or rolled back in the meantime. That's bounded by maxAgeSeconds. The default (the poll interval, 60 s in production) keeps the same bound polling already gives: "a change reaches every instance within one poll interval". A larger value saves more proxy invocations and makes pauses and auto-rollbacks slower to reach returning visitors. Sticky assignment doesn't depend on it: it lives in evolve_a, so the visitor keeps their arm when the proxy runs again.
Set it up with npx next-evolve init --cdn-routing (it writes all three pieces), or by hand:
// evolve.config.ts
export default defineConfig({
routes: { "/": { mode: "static" } },
cdnRouting: true, // or { maxAgeSeconds: 60 }
});
// next.config.ts
import type { NextConfig } from "next";
import { evolveRewrites } from "next-evolve/config";
import evolveConfig from "./evolve.config";
const nextConfig: NextConfig = {
async rewrites() {
// Keep Evolve's first if you have your own beforeFiles rewrites.
return { beforeFiles: evolveRewrites(evolveConfig), afterFiles: [], fallback: [] };
},
};
export default nextConfig;
proxy.ts needs config.matcher to be the literal evolveMatcher(evolveConfig) returns (Next only accepts a literal there). init --cdn-routing writes it, and npx next-evolve doctor prints it whenever the one in proxy.ts differs. It keeps the plain entries for dynamic routes and /(ev_.*), and for each static route adds three: run the proxy unless the route cookie holds a well-formed code, and always for previews (evolve_pv cookie or ?evolve_preview). Re-run init --cdn-routing (or update the literal) when you add a route; npx next-evolve doctor fails when the literal and evolve.config disagree, or when next.config doesn't call evolveRewrites().
What stays the same:
- Off by default. Without
cdnRouting,evolveRewrites()returns[],evolveMatcher()is the plain list, and the proxy is unchanged. - No route cookie for bots, without consent, for previews, on dynamic-mode routes, or when no experiment runs on the route.
- Previews always run the proxy.
/ev_*paths are still never served directly (the proxy answers 404).
Forged cookies. A visitor can edit their own route cookie. A code that isn't well-formed doesn't skip the proxy (it re-issues the cookie). A well-formed code the bundle doesn't know renders control, marked as not counted, and refetches the bundle at most once per 5 s per instance (like collect). Each distinct forged code still costs one page render (an ISR entry), the same as a request for any unknown [slug] page. It only ever affects the visitor who forged it.
Hosting. Built for Vercel, whose CDN caches each rewritten variant path separately. Off Vercel, CDN-routed responses don't carry the proxy's Cache-Control: private, no-store, so a shared cache in front of the app must not cache tested routes by URL alone; otherwise leave cdnRouting off (it saves little on next start, where the proxy costs ~0.5 ms).
#Database
next-evolve migrate creates a schema named evolve with these tables: exposures (one row per visitor per experiment), events, identities, usage_daily (tested page views per day), vitals_daily (Core Web Vitals histograms: counts per experiment, variant, metric, day and bin, no visitor id; since 0.5), aggregate_runs (the last 24 h of aggregate responses, for retries), experiment_activity and cleanup_runs (for retentionDays; since 0.7) and migrations. Your own tables aren't touched. Rows are kept until you delete them, or until retentionDays does.
#Retention (retentionDays)
With retentionDays: 180 (minimum 30) the SDK deletes old rows once a day. There's no cron to set up: the cleanup runs inside the cloud's aggregate requests (push or pull mode), after the aggregation and within its time budget (at most 10 s per request). Deletes go in batches of 5,000 rows with a 5 s statement timeout, so they never lock evolve.events; a day's cleanup that runs out of time is finished by the next request.
What's deleted:
eventswhose time and arrival are older than the cutoff;exposuresolder thanretentionDaysof experiments the cloud no longer aggregates (stopped or archived);vitals_dailydays older than the cutoff.
The cutoff is retentionDays ago, or earlier if a running or paused experiment needs it: everything from one day before the earliest exposure of any experiment the cloud still asks about is kept, because its daily full recount re-reads from its start (goal windows and the 7-day guardrail windows included). Exposures of running and paused experiments, identities and usage_daily are never deleted. The SDK learns which experiments are active from the aggregate requests themselves (evolve.experiment_activity), and deletes nothing until it has tracked them for 26 hours (one daily full recount). Needs npx next-evolve migrate (0003); without it the cleanup is skipped with a warning. evolve.cleanup_runs records what each day deleted.