Skip to content
Docsnext-evolve 0.9.0BlognpmSign in
Docs menu

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:

  1. The first view runs the proxy as usual. It also sets evolve_r_<hash> (one per route) to the variant code it served, for maxAgeSeconds.
  2. While that cookie lives, a next.config rewrite 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:

  • events whose time and arrival are older than the cutoff;
  • exposures older than retentionDays of experiments the cloud no longer aggregates (stopped or archived);
  • vitals_daily days 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.

From the next-evolve 0.9.0 docs (also on npm in the package README). Something wrong or unclear? Tell us with the feedback button in the dashboard.