High performance request-time experimentation decisioning for Next.js via proxy.ts, before route rendering begins.
This repo is an example for running @optimizely/edge-delivery in a Next.js 16 App Router project deployed on Vercel. It moves Optimizely decisioning out of a client-side snippet and into the server request path, avoiding the snippet-vs-hydration timing conflicts that cause flicker in React SSR apps.
@optimizely/edge-delivery was originally built for Cloudflare Workers and expects Cloudflare-specific runtime APIs - notably headers.getAll('set-cookie') and an HTMLRewriter global. This repo provides a small compatibility layer in proxy.ts (a getAll adapter on Headers) and uses the htmlrewriter npm package to supply a compatible HTMLRewriter implementation.
Optimizely Web Experimentation traditionally works by injecting a client-side snippet (<script>) into the page. The snippet runs in the browser after the document loads, evaluates audiences, assigns a variation, and mutates the DOM.
This works, but it comes with inherent tradeoffs:
| Snippet (client-side) | Edge Delivery (request-time) | |
|---|---|---|
| When decisions happen | After page load, in the browser | During the server request, before route rendering |
| First paint | User may see control content before the variation is applied (flicker) | Variation content is in the HTML when the response arrives |
| Hydration safety | DOM mutations conflict with React hydration -- React can overwrite variation changes | Server-rendered HTML already contains the variation; no post-load DOM mutation needed |
| First-visit personalization | Requires waiting for the snippet to download, parse, and execute | Decision is made on the first request, during server-side processing |
| Render-blocking cost | Snippet must block rendering or accept flicker | No client-side blocking; decisioning has already happened on the server |
| Network dependency | Relies on CDN delivery of the snippet JS before experimentation begins | Datafile is fetched server-side; the visitor does not wait for it |
The core difference: with edge delivery, the visitor's assigned variation is resolved during the server request and included in the response HTML. The browser does not need to download, parse, or execute a snippet before the experiment takes effect.
There are two layers: the proxy (proxy.ts) and the page (app/page.tsx and its supporting lib/ modules). The proxy runs first and handles Optimizely decisioning; the page renders afterward using the decisions the proxy already made.
In Next.js 16, proxy.ts uses the Node.js runtime. On Vercel, this means the proxy logic runs as a serverless function deployed globally across regions. It executes on every matching request at the network boundary, before route rendering and before the response cache is checked, with full access to standard Node.js request APIs. It is high-performance globally distributed serverless request handling.
proxy.ts is a Next.js Proxy (the successor to Middleware in Next.js 16) that intercepts HTML GET requests before route rendering begins. It handles assignment only -- it does not fetch or transform the page HTML.
First visit (no VMAP cookie present):
- Preflight -- The proxy calls
applyExperimentsagainst a dummy HTML document to force Optimizely to evaluate audiences and assign a variation. This produces stickySet-Cookieheaders (VMAP cookies) containing the assignment. - Forward upstream -- The assignment cookies are merged into the request headers via
NextResponse.next({ request: { headers } })so the downstream page render sees them immediately on this same request. - Set on response -- The same
Set-Cookieheaders are set on the outgoing response so the browser persists them for subsequent requests.
Repeat visits (VMAP cookie already present):
The proxy passes the request through with no additional work. The page reads the existing assignment cookies directly.
- Read cookies --
app/page.tsxcallscookies()and passes the cookie store togetDecisionContext()(lib/optimizely/cookies.ts), which parses the VMAP cookies into a structuredDecisionContextcontaining the visitor's assigned variation IDs. - Resolve copy -- The page calls
resolveCopyField(context, field)(lib/optimizely/content.ts) for each experimented field. This function looks up the assigned variation ID from the context, fetches the Optimizely datafile (cached server-side with a configurable TTL), and extracts the marketer-authored copy value for that variation and field selector. - Render -- The resolved copy values are passed directly into JSX. The rendered HTML already contains the variation content -- React hydration will see exactly what the server rendered.
The contract between these layers is the VMAP cookie. The proxy writes it; the page reads it. Field keys and selectors are defined in lib/optimizely/config.ts so both layers agree on what is being experimented. Copy values are marketer-owned in Optimizely and resolved at runtime from the datafile -- developers do not hardcode variation text.
- Request-time decisioning in
proxy.tswith first-visit assignment on the same request. - Hydration-consistent rendering -- React server components read the same assignment cookies the proxy set.
- Code-owned contract -- field keys and selectors are defined in code; copy values are marketer-owned in Optimizely.
- Reduced flicker risk -- for contract-managed fields, variation content is in the response HTML without relying on client-side snippet DOM mutations.
proxy.ts decides and sets cookies; React components render from that decision contract.
In App Router apps, React hydrates server-rendered HTML. If experimentation changes are applied outside React's render contract (e.g., via client-side DOM mutation), hydration can overwrite those changes. Symptoms include:
- Flicker (control -> variation -> control, or variation -> control)
- First-load inconsistency
- Visual editor changes that appear in raw HTML but disappear after hydration
This repo addresses that with a shared decision contract: the proxy performs decisioning at the request layer, and React renders from the same sticky assignment signal -- both agree on what the visitor should see.
proxy.ts-- request interception, first-visit preflight assignment viaapplyExperiments, cookie forwarding.lib/optimizely/cookies.ts-- parse sticky VMAP cookies from the assignment.lib/optimizely/resolver.ts-- resolve the assigned variation ID for an experiment.lib/optimizely/config.ts-- locked copy field contract (key + selectors + defaults).lib/optimizely/content.ts-- fetch Optimizely datafile and resolve variation copy values.app/page.tsx-- variation-aware server rendering example.
React hydration is a normal part of how SSR works: the server renders HTML, the browser displays it, and then React "hydrates" the page by reconciling the DOM against its virtual DOM to attach interactivity. This is standard React behavior, regardless of hosting provider.
The challenge for experimentation is that if a client-side snippet mutates the DOM to apply variations before hydration runs, React will overwrite those changes -- it reconciles back to what it rendered on the server. The result is flicker or lost variations.
Optimizely documents this directly and offers several client-side mitigations for snippet-based setups:
| Mitigation | Approach | Limitation |
|---|---|---|
| Prevent auto-execution | Disable snippet execution; manually activate post-hydration via useEffect |
Experimentation delayed until after first paint |
| Force reactivation | Deactivate/reactivate all Optimizely pages in a root useEffect after hydration |
Brief flicker as variations are applied, lost, then reapplied |
| DOM Change triggers | Use MutationObserver to detect hydration changes and reapply variations |
Timing-sensitive; Optimizely notes "inconsistent" efficacy with hydration |
| Marker-based activation | Add markers (CSS classes/data attributes) to components; activate per-marker post-hydration | Still post-hydration; requires coordinating markers in code with Optimizely config |
| Reduce SSR/client discrepancies | Minimize DOM differences so hydration has less to reconcile | Experiments inherently create discrepancies; damage reduction, not a solution |
See the full documentation for implementation details on each.
This repo takes an approach most similar to marker-based activation -- it coordinates a shared configuration between Optimizely and the application code (field keys, selectors, experiment IDs defined in lib/optimizely/config.ts). The difference is that instead of using those markers to trigger client-side snippet activation post-hydration, the coordination happens at the request layer in proxy.ts. The variation is resolved during the server request and included in the rendered HTML, so there is no post-hydration DOM mutation for React to overwrite.
Any of the mitigations Optimizely documents are valid approaches -- this repo is just one example. You should adopt whichever strategy best fits your application's architecture, experimentation requirements, and performance constraints.
-
@optimizely/edge-delivery- https://github.com/optimizely/edge-delivery
- The core Optimizely Web Experimentation runtime used in the proxy. Handles audience evaluation, bucketing, sticky cookie assignment, and HTML transforms.
-
htmlrewriter- https://github.com/remorses/htmlrewriter
@optimizely/edge-deliveryrequires anHTMLRewriterimplementation as part of its options interface. This package provides a compatible implementation that works in Node runtime on Vercel. It is used during the preflight decision call even though this repo does not use the full HTML transform pipeline.
-
next- https://nextjs.org/docs
- Provides
proxy.tsrequest interception and App Router server rendering primitives (cookies()) used for hydration-consistent rendering.
OPTLY_SNIPPET_ID(required)OPTLY_ACCOUNT_ID(optional)OPTLY_ENV(optional, defaults toprod)OPTLY_FALLBACK(optional, defaults toerror)OPTLY_LOG_LEVEL(optional, defaults toinfo)OPTLY_POSITION(optional, defaults tobottom)OPTLY_USE_EDGE_DELIVERY_SNIPPET(optional, defaults tofalse)OPTLY_DATAFILE_TTL_MS(optional, defaults to30000)
npm install
npm run devOpen http://localhost:3000.
- Clear cookies for the app domain.
- Load
/in a clean browser session. - Confirm the experimented element shows the assigned variant on first load.
- Refresh and confirm the variant remains stable.