Forcing an A/B test variant (Next.js Pages Router)

Last updated: March 23, 2026

Executive summary

What this does: You can share a link that shows your site as if a specific Uniform A/B test variation won—useful for reviews, QA, or demos—by adding one query parameter to the URL.

How marketers / PMs use it: Append uniform_force_ab_test_variant_id=<variant-id> to any page URL (use the variation’s Context tag id from Uniform, not only the human-readable label). Example:

https://yoursite.com/some-page?uniform_force_ab_test_variant_id=your-variant-id

What happens under the hood (two complementary mechanisms):

  1. Visitor cookie (ufvd) — The server adjusts Uniform’s visitor cookie so stored A/B assignments line up with the forced variant where applicable. This supports Uniform Context behavior during SSR (personalization, tests resolved via context).

  2. Canvas composition — For Canvas $test components, the server temporarily sets that variation’s audience share to 100% and the others in the same test to 0% in the payload sent to the page. Only tests that actually contain a variation with that id are changed; other tests behave normally.

Important limitations:

  • If a visitor already has an assignment for a test in their cookie, Context may keep that assignment; the composition skew mainly affects the “random pick” path for new assignments. To test it properly, run the variant forcing in incognito as a fresh request:

    http://localhost:3003/?uniform_force_ab_test_variant_id=<your=variant-id>

  • This starter skips both behaviors in Next.js preview mode (context.preview), so draft preview in the CMS is not distorted.

  • Treat forced URLs as a power user / internal feature unless you add auth or strip the parameter from analytics.


Deep dive: architecture and integration

Reference starter

The code with this change applied to Uniform's vanilla Next.js page router starter is here for your reference, you can deploy it to test and see how it works before integrating it with your own Next.js codebase:

https://github.com/uniformdev/examples/tree/example/force-abtest-variation

Problem shape

Uniform picks A/B variations using:

  • Context — Visitor state in the ufvd cookie, read during SSR when you call createUniformContext (this starter does that in _document.tsx via NextCookieTransitionDataStore).

  • Canvas $test trees — Variations under a $test component carry $tstVrnt parameters (id, testDistribution, etc.). The Context runtime picks a variation using stored ids when present, otherwise weighted random using normalized distributions.

To “force” a link, you must influence one or both of those paths before the React tree and Context consume them.

Why getServerSideProps alone is not enough for Context

In the Pages Router, Document.getInitialProps runs createUniformContext(ctx) before it calls Document.getInitialPropsrenderPage() → your page’s getServerSideProps. The cookie store reads req.headers.cookie when the Context is constructed.

Therefore:

  • Patching cookies only at the start of getServerSideProps is too late for <UniformContext /> / enableNextSsr in _document.

  • You must patch the incoming Node request in _document.getInitialProps, before createUniformContext, or use Edge middleware that rewrites the Cookie header before the request hits Node (this starter does not ship root middleware; _document is enough here).

Layer 1 — Cookie / ufvd (forceAbTestVariantCookie.ts)

File: lib/uniform/forceAbTestVariantCookie.ts

Entry point for SSR: applyForcedAbTestVariantToIncomingMessage(req, query)

  • Reads uniform_force_ab_test_variant_id from the Next query object (same param name as Uniform’s docs).

  • If present and an ufvd cookie exists, re-encodes the A/B portion of ufvd so every test entry is rewritten to use the forced variant id (same string manipulation as the App Router middleware recipe).

  • Mutates req.headers.cookie in place so all later server code sees the updated header.

Exports useful for integration:

Export

Purpose

FORCE_AB_TEST_VARIANT_QUERY_PARAM

Constant "uniform_force_ab_test_variant_id" (e.g. analytics allowlists / stripping).

getForcedAbTestVariantFromQuery(query)

Safe read from ParsedUrlQuery.

buildCookieHeaderWithForcedAbTestVariant(pairs, id)

Low-level: new Cookie header string or null.

applyForcedAbTestVariantToIncomingMessage(req, query)

Call from _document before createUniformContext.

Dependency: Uses the [cookie](https://www.npmjs.com/package/cookie) package to parse the Cookie header (already pulled in via @uniformdev/context-next in typical installs).

Wire-up in this starter: pages/_document.tsx calls applyForcedAbTestVariantToIncomingMessage(ctx.req, ctx.query) immediately before createUniformContext(ctx).

Layer 2 — Canvas composition distributions (forceCanvasTestVariantInComposition.ts)

File: lib/uniform/forceCanvasTestVariantInComposition.ts

Entry point: applyForcedCanvasTestVariantDistributions(composition, forcedVariantId)

  • Walks the composition tree (RootComponentInstance).

  • For each $test node (CANVAS_TEST_TYPE), inspects the test slot (CANVAS_TEST_SLOT).

  • If any child has $tstVrnt (CANVAS_TEST_VARIANT_PARAM) with value.id === forcedVariantId, then for all variants in that slot sets testDistribution to 100 (match) or 0 (others).

  • If no variant in that test uses that id, the test is unchanged (per the Notion Pages Router behavior).

Wire-up in this starter: pages/[[...slug]].tsx (and the .ssr duplicate) inside withUniformGetServerSidePropshandleComposition:

const forcedVariantId = getForcedAbTestVariantFromQuery(context.query);
if (!context.preview && composition && forcedVariantId) {
  applyForcedCanvasTestVariantDistributions(composition, forcedVariantId);
}

How the two layers differ

Aspect

Cookie (ufvd) rewrite

Composition skew

Scope

Rewrites all A/B slots in ufvd to the same variant id

Only $test blocks that include that variant id

Best when

You need Context-aligned behavior similar to App Router forcing

You want Canvas-native behavior: leave unrelated tests alone

Existing cookie assignment

Can override encoded assignments for tests in ufvd

If Context already chose a variant for a test, that id may still win over distributions

Runs

_document (before Context)

handleComposition (after fetch, before props)

Using both covers typical Canvas + Context SSR setups; understand the existing assignment caveat so QA knows when to clear cookies or use incognito.

Integrating into an existing Next.js (Pages Router) + Uniform app

  1. Copy the two library modules into your repo (adjust import paths):

    • lib/uniform/forceAbTestVariantCookie.ts

    • lib/uniform/forceCanvasTestVariantInComposition.ts

  2. _document.tsx — If you use Uniform Context SSR (createUniformContext + enableNextSsr in getInitialProps), add before createUniformContext:

     if (ctx.req) {
       applyForcedAbTestVariantToIncomingMessage(ctx.req, ctx.query);
     }
  3. getServerSideProps with Canvas — In every route that uses withUniformGetServerSideProps (or your own fetch + composition pipeline), after you have a composition object and outside preview:

     import { getForcedAbTestVariantFromQuery } from "@/lib/uniform/forceAbTestVariantCookie";
     import { applyForcedCanvasTestVariantDistributions } from "@/lib/uniform/forceCanvasTestVariantInComposition";
    
     // inside handleComposition (or equivalent):
     const forcedVariantId = getForcedAbTestVariantFromQuery(context.query);
     if (!context.preview && composition && forcedVariantId) {
       applyForcedCanvasTestVariantDistributions(composition, forcedVariantId);
     }
  4. SSG (getStaticProps) — This pattern runs per request only where you have getServerSideProps (or middleware + client fetch). Static pages built at build time will not re-run handleComposition per visitor; use SSR for routes that must honor the query param, or adopt a different strategy (e.g. client-side composition fetch with the param).

  5. Optional: Edge middleware — If you cannot use _document (unusual for Pages Router) or want the cookie rewritten before Node, you can port the cookie rewrite to middleware.ts using NextResponse.next({ request: { headers } }) as in Uniform’s App Router article. This starter removed duplicate root middleware because _document already applies the cookie patch on Node.

  6. Production hygiene (recommended by Uniform):

    • Restrict who can use forced URLs (secret token, IP allowlist, or environment check).

    • Exclude requests carrying uniform_force_ab_test_variant_id from conversion/analytics or treat them as internal traffic.

Files in this starter

File

Role

lib/uniform/forceAbTestVariantCookie.ts

Query parsing + ufvd / Cookie header rewrite

lib/uniform/forceCanvasTestVariantInComposition.ts

$test tree walk + testDistribution skew

pages/_document.tsx

Invokes cookie layer before createUniformContext

pages/[[...slug]].tsx

Invokes composition layer in handleComposition

Finding the variant id

Use the variation’s Context tag id in Uniform (the value stored on $tstVrnt / what appears in APIs as the variant’s id in test placement), not necessarily the display “Variation name” string. If the wrong id is used, the composition layer no-ops for that test; the cookie layer may still rewrite ufvd globally, which can be undesirable—so validating ids matters.