Assignment and targeting
How people are identified and assigned a version, what targeting rules can match, and how consent works.
Assignment decides which version a person sees. It happens in the Next.js proxy on each request, is deterministic, and is the same for the same person on every request and every server instance.
Units: visitors and signed-in users
A test counts one of two units:
- Visitors (
visitor, the default). Each browser gets a signed, opaque id in thexp_idcookie. - Signed-in users (
user). The unit is your user id, so a person sees the same version on every device. You supply it throughgetUnitandgetUser(see Signed-in users).
Page tests always use visitors.
How assignment works
Each test round has a random seed. A person's version is chosen by hashing the seed with their unit id (GrowthBook's SDK hash, version 2) and mapping the result onto the group percentages. Because the hash is deterministic, no assignment table is stored; any instance computes the same answer from the signed cookie and the manifest.
- Group percentages are whole numbers that total 100. A holdout is an explicit group, never a dropped remainder.
- Allocation is applied separately. Raising allocation widens each group's range, so nobody already taking part moves to another group.
- A new round gets a new seed, so people are reassigned.
The proxy writes the assignment into a signed x-xp-assignments request header (at most 1 KB). Server code reads it with getAssignments, which verifies the signature and recomputes any stale or missing entries. A changed or unsigned header is ignored.
The visitor cookie
| Property | Value |
|---|---|
| Name | xp_id (change with cookie.name) |
| Lifetime | 365 days (change with cookie.maxAgeDays) |
| Flags | HttpOnly, SameSite=Lax, Secure on HTTPS, path / |
| Content | A signed opaque id. No personal data. |
A newly created cookie is forwarded into the same request, so server components see the assignment on the first visit.
Targeting
Targeting rules limit a test to people with matching attributes. All rules must match. People who do not match see the Original and are not counted.
Attributes come from the server, never from the browser:
path, owned by the plugin: the request path with no search string and no trailing slash (except/). Block tests run where their block is placed, so they cannot use apathrule; it is offered for setting and code tests.loggedInandstaff, from yourgetUnitandattributesOf.- Your own attributes, declared in the plugin's
attributeKeys.
Declare each attribute with a type, and optionally a list of string values for a dropdown in the admin:
createExperimentsPlugin({
// ...
attributeKeys: {
staff: { type: 'boolean' },
plan: { type: 'string', values: ['free', 'pro'] },
visits: { type: 'number' },
},
})| Type | Operators |
|---|---|
string | eq, neq, in, nin, contains (all case-insensitive) |
number | eq, neq, gt, gte, lt, lte |
boolean | eq |
A rule on an attribute the request does not have never matches. An in or nin rule needs at least one value. Pass only attributes that do not identify a person.
Consent
To enrol people only after consent, give the proxy runtime a cookie.consent(request) function. It must read cookies and headers only: server helpers call it with a placeholder URL. If it throws, consent is treated as declined.
When it returns false:
- Nobody is enrolled, including signed-in users. No visitor id is created or honoured, and an existing
xp_idcookie is removed from the forwarded request. - Running tests and decided tests with an open holdout round show the Original. Other decided tests show their winner, subject to targeting, and archived page routing still applies. None of these record an exposure.
linkIdentityandtrackConversiondo nothing.
Leaving consent out permits the cookie and enrolment.
Preview
The setup checks open each version with a signed xp_preview token. A preview shows the chosen version without counting it, and preview responses are not cached.

