Skip to content

Repository files navigation

Optimizely Edge Delivery on Vercel

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.

What is edge delivery?

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.

How it works on Vercel

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.

Execution model

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 layer (proxy.ts) -- assign-only

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):

  1. Preflight -- The proxy calls applyExperiments against a dummy HTML document to force Optimizely to evaluate audiences and assign a variation. This produces sticky Set-Cookie headers (VMAP cookies) containing the assignment.
  2. 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.
  3. Set on response -- The same Set-Cookie headers 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.

Page layer (app/page.tsx + lib/optimizely/)

  1. Read cookies -- app/page.tsx calls cookies() and passes the cookie store to getDecisionContext() (lib/optimizely/cookies.ts), which parses the VMAP cookies into a structured DecisionContext containing the visitor's assigned variation IDs.
  2. 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.
  3. 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.

What this demonstrates

  • Request-time decisioning in proxy.ts with 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.

Core idea

proxy.ts decides and sets cookies; React components render from that decision contract.

Why this is tricky in Next.js/React

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.

Files

  • proxy.ts -- request interception, first-visit preflight assignment via applyExperiments, 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 SSR and hydration: how this repo approaches it

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.

Dependencies

  • @optimizely/edge-delivery

  • htmlrewriter

    • https://github.com/remorses/htmlrewriter
    • @optimizely/edge-delivery requires an HTMLRewriter implementation 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.ts request interception and App Router server rendering primitives (cookies()) used for hydration-consistent rendering.

Required env vars

  • OPTLY_SNIPPET_ID (required)
  • OPTLY_ACCOUNT_ID (optional)
  • OPTLY_ENV (optional, defaults to prod)
  • OPTLY_FALLBACK (optional, defaults to error)
  • OPTLY_LOG_LEVEL (optional, defaults to info)
  • OPTLY_POSITION (optional, defaults to bottom)
  • OPTLY_USE_EDGE_DELIVERY_SNIPPET (optional, defaults to false)
  • OPTLY_DATAFILE_TTL_MS (optional, defaults to 30000)

Run locally

npm install
npm run dev

Open http://localhost:3000.

Manual validation

  1. Clear cookies for the app domain.
  2. Load / in a clean browser session.
  3. Confirm the experimented element shows the assigned variant on first load.
  4. Refresh and confirm the variant remains stable.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages