Use cases
Edge A/B testing with deterministic assignment
Apertio assigns variants at the edge with deterministic cookie-hash bucketing: the same unit id and salt always produce the same variant. The hash is pure arithmetic, so it runs unchanged in middleware, a Server Component, and on the client.
Edge A/B testing means deciding a visitor’s variant at the edge, before the request reaches your application server, and doing it the same way every time for the same visitor. Apertio assigns variants with deterministic cookie-hash bucketing. It hashes the visitor’s stable unit id together with the experiment’s salt, maps the result to a bucket in the range zero to one, and selects a variant by weighted split. The same unit id and salt always produce the same bucket, so a visitor stays in their variant across requests, page loads, and sessions.
The hash is pure arithmetic with no Node crypto, so it runs in an edge runtime unchanged. The same function computes the same assignment in middleware, in a Server Component, and on the client.
Assign at the edge, resolve and render downstream
In Next.js, middleware cannot render React, but it is the right place to assign. Apertio’s middleware helper reads the assignment cookie and, on a visitor’s first request, mints a stable unit id and sets the cookie on the response. The downstream RSC layer then resolves from that cookie with no extra round trip. This is the assignment workhorse: assign at the edge, resolve and render downstream.
Because assignment is a pure function of the unit id and the experiment salt, middleware can also compute the variant itself. That is what an edge rewrite needs: resolve the variant for the unit, then rewrite the visitor to a variant-specific path. This is how Apertio handles ISR, where the cached HTML is shared across visitors and per-visitor variants cannot live in it directly. The edge rewrites each visitor to a pre-rendered per-variant page that already carries their variant, which stays flicker-free because the assignment is decided before the cached page is served.
Assign at the edge, then rewrite to a per-variant page
// middleware.ts — assign at the edge, then rewrite to a per-variant page.
import { NextResponse, type NextRequest } from 'next/server'
import { assignVariant } from '@apertio/core'
import { ensureAssignment, APERTIO_UNIT_COOKIE } from '@apertio/next/middleware'
import { clientConfig } from '@/apertio/config.client'
const EXPERIMENT_ID = 'home-hero'
// Pure, edge-safe: same unit id + salt always returns the same variant id.
function variantFor(unitId: string): string | null {
const exp = clientConfig.experiments.find((e) => e.id === EXPERIMENT_ID)
if (exp === undefined || !exp.active) return null
return assignVariant(
unitId,
exp.salt, // each experiment's own salt keeps assignments independent
exp.variants.map((v) => ({ id: v.id, weight: v.weight })),
)
}
export default function middleware(request: NextRequest) {
// Ensure the stable bucketing cookie exists, minting one on first request.
const { response, unitId } = ensureAssignment(request, NextResponse.next())
const variant = variantFor(unitId)
if (variant !== null) {
const url = request.nextUrl.clone()
url.pathname = `/home/${variant}` // rewrite to the per-variant cached page
const rewrite = NextResponse.rewrite(url)
const cookie = response.cookies.get(APERTIO_UNIT_COOKIE)
if (cookie !== undefined) {
rewrite.cookies.set(APERTIO_UNIT_COOKIE, cookie.value, {
path: '/',
sameSite: 'lax',
httpOnly: false, // the bucketing id is not an auth token
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365,
})
}
return rewrite
}
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
} assignVariant is the same function the rest of the SDK uses, so the edge and the server agree.
assignVariant is the same function the rest of the SDK uses, so the variant middleware computes at the edge matches the variant a Server Component would resolve for the same unit. The cookie is deliberately not httpOnly because the bucketing id is not an auth token and the client-resolution modes need to read it. It is still restricted to HTTPS in production.
Deterministic by construction
hash(unitId + experimentSalt) maps to a stable bucket, so the same visitor lands in the same variant every time. No per-visitor lookup table.
Edge-safe, no crypto dependency
The hash is pure arithmetic with no Node crypto, so it runs unchanged in an edge runtime, in a Server Component, and on the client. One function, identical results everywhere.
Independent per experiment
Each experiment carries its own salt, so a visitor’s bucket in one experiment is statistically independent of another. Concurrent experiments on different slots do not collide.
SRM-detectable splits
Assignment is a weighted split you configure. If a configured 50/50 split delivers something else, that sample ratio mismatch is detectable as a signal that something is wrong rather than a silent skew.
Resolution downstream, and the roadmap boundary
Once the edge has set the cookie, resolution downstream is a pure function of the assignment and the published config. The config reaches the SDK as an HTTP JSON snapshot, cached in memory per instance and refreshed in the background with stale-while-revalidate through a ConfigSource interface. When a slot resolves to a live experiment, the SDK emits one exposure event carrying the unit id, experiment id, variant id, and timestamp. Exposure events flow through a pluggable sink (the EventSink interface), so you can route them to your own analytics or warehouse and keep experiment data in your boundary.
Note on scope: the deterministic assignment and the Next.js middleware helper above are built and run in the demo today. A dedicated edge key-value config adapter, such as Vercel Edge Config or Cloudflare KV, is on the roadmap as a later add-on, not the shipped default. The base model is the in-memory HTTP snapshot described above. Assignment itself is edge-ready now because it needs no config store to compute; it only needs the unit id and the experiment salt.
The honest limitation holds here too: only modeled slots are testable. Edge assignment decides which variant a visitor gets, but the variants are still bundles of values for slots an engineer declared in code. A surface with no slot cannot be tested until someone adds one.
Assign every visitor a stable variant at the edge
Apertio is open source and MIT licensed. Read the docs to wire up edge assignment, or start free.
Frequently asked questions
- How does Apertio decide which variant a visitor gets at the edge?
- It hashes the visitor’s stable unit id with the experiment’s salt, maps the hash to a bucket between zero and one, and selects a variant by weighted split. The hash is pure arithmetic, so it runs in an edge runtime with no crypto dependency.
- Is the assignment stable across requests and sessions?
- Yes. The unit id is persisted in a cookie and the hash is deterministic, so the same unit id and experiment salt always produce the same variant. A visitor stays in their variant across requests, reloads, and sessions.
- Does edge assignment require a special config store?
- No. Assignment only needs the unit id and the experiment salt, so it computes at the edge with no config store. The config snapshot itself is delivered as an HTTP JSON payload cached in memory. An edge key-value adapter is a roadmap add-on, not a requirement.
- Can I run several experiments at once without them interfering?
- Yes. Each experiment carries its own salt, so a visitor’s bucket in one experiment is statistically independent of another. Because slots are typed and declared, two experiments that touch the same slot are detectable before launch.