SoftwareCrafting Logo

Zero-Flicker A/B Testing: Architecting Edge Personalization with Next.js Middleware and AWS CloudFront Functions

SSoftwareCraftingWeb Development7 min read06 Apr 2026
Illustration for Zero-Flicker A/B Testing: Architecting Edge Personalization with Next.js Middleware and AWS CloudFront Functions

TL;DR: Eliminate UI flickering and Cumulative Layout Shift (CLS) in Next.js A/B tests by shifting variant assignment and routing to the edge. By combining AWS CloudFront Functions to normalize cookie cache keys and Next.js Middleware to transparently rewrite requests, you can serve statically generated variants without destroying your CDN cache hit rates.

⚑ Key Takeaways

  • Avoid client-side variant swapping with useEffect and feature flag SDKs, which force blank loading states and degrade Largest Contentful Paint (LCP).
  • Use a CloudFront Function on the Viewer Request event to strip tracking cookies (like _ga) and forward only the ab_variant cookie to prevent CDN cache fragmentation.
  • Choose CloudFront Functions over Lambda@Edge for cache normalization to achieve sub-millisecond execution times and reduce costs (~$0.10 vs $0.60+ per million invocations).
  • Implement Next.js Middleware to read the ab_variant cookie, assign a variant via Set-Cookie if missing, and transparently rewrite the URL to the corresponding variant route.
  • Pre-render all A/B test variants at build time using the Next.js App Router and generateStaticParams to ensure 100% edge cacheability and zero-flicker delivery.

You launch your highly anticipated A/B test. The user navigates to the landing page, the hero section loads in default blue, and 300 milliseconds laterβ€”snapβ€”it flashes to the experimental red variant.

Your Cumulative Layout Shift (CLS) skyrockets, your Largest Contentful Paint (LCP) feels sluggish, and the user experience is visibly degraded. You've just fallen victim to the fundamental flaw of client-side personalization in Server-Side Rendered (SSR) applications.

Traditional A/B testing SDKs rely on the browser to fetch feature flags, evaluate targeting rules, and swap out React components. In a Next.js App Router ecosystem, this results in unavoidable hydration mismatches or forced client-side rendering delays. If you attempt to solve this purely on the Next.js origin server without careful cache management, you destroy your CDN cache hit rates, sending infrastructure costs through the roof.

The solution requires pushing the routing and variant assignment layer to the edge. By combining AWS CloudFront Functions for sub-millisecond cache normalization and Next.js Middleware for transparent request rewrites, you can serve statically generated, deeply personalized variants with zero UI flickering and 100% edge cacheability.

Here is the blueprint for building a production-grade, zero-flicker A/B testing pipeline.

The Fatal Flaw of Client-Side A/B Testing

To understand why edge routing is necessary, look at the standard client-side implementation most engineering teams start with:

// ❌ The Anti-Pattern: Client-side variant swapping
'use client';

import { useEffect, useState } from 'react';
import { useFeatureFlags } from '@thirdparty/sdk';
import HeroRed from './HeroRed';
import HeroBlue from './HeroBlue';

export default function HeroSection() {
  const [mounted, setMounted] = useState(false);
  const { variant } = useFeatureFlags('hero_test_v1');

  useEffect(() => {
    setMounted(true);
  }, []);

  // Prevents hydration mismatch, but forces a blank state or delayed render
  if (!mounted) return <div className="h-96 bg-gray-100 animate-pulse" />;

  return variant === 'treatment' ? <HeroRed /> : <HeroBlue />;
}

This approach forces the client to download the JavaScript bundle, boot React, execute the useEffect hook, and re-render. Best-case scenario: the user sees a loading skeleton. Worst-case scenario: they see the control variant flash before the treatment overrides it.

When we implement global platforms in our full-stack web development services, eliminating this layout shift is a hard requirement. The performance delta directly correlates to higher conversion rates.

Edge-Driven Personalization Architecture

To fix this, we split the responsibilities between the CDN edge and the Next.js application server:

  1. AWS CloudFront (Viewer Request): Normalizes cookies to prevent cache fragmentation and forwards only the A/B test cookie to the origin.
  2. Next.js Middleware: Reads the cookie. If missing, it assigns a variant and appends a Set-Cookie header. It then transparently rewrites the request URL to a specific Next.js route (e.g., /treatment).
  3. Next.js App Router: Statically generates all variants at build time using generateStaticParams.

Production Note: Why use CloudFront Functions instead of Lambda@Edge? Execution time and cost. CloudFront Functions execute in < 1ms directly at the Edge PoP and are significantly cheaper. If you are evaluating infrastructure pricing and architecture costs, CloudFront Functions cost around $0.10 per 1 million invocations compared to Lambda@Edge's $0.60+ per million (plus duration charges).

Phase 1: Normalizing Cache Keys with CloudFront Functions

By default, forwarding the Cookie header to your Next.js origin will destroy your CDN cache hit rate. Every user has unique tracking cookies (like _ga or _fbp), meaning CloudFront will treat every request as a unique cache key.

We use a CloudFront Function on the Viewer Request event to parse the cookies, strip out everything except our specific ab_variant cookie, and pass a normalized header to the origin.

// cloudfront-function-viewer-request.js
function handler(event) {
  var request = event.request;
  var headers = request.headers;
  var cookies = request.cookies;

  var normalizedCookies = {};

  // Check if our specific A/B testing cookie exists
  if (cookies && cookies['ab_variant']) {
    normalizedCookies['ab_variant'] = cookies['ab_variant'];
  }

  // Overwrite the cookie header to ONLY contain our A/B variant.
  // This ensures CloudFront caches based on the variant, not analytics cookies.
  var cookieStrings = [];
  for (var key in normalizedCookies) {
    cookieStrings.push(key + '=' + normalizedCookies[key].value);
  }

  if (cookieStrings.length > 0) {
    headers['cookie'] = { value: cookieStrings.join('; ') };
  } else {
    delete headers['cookie'];
  }

  return request;
}

You must also configure your CloudFront Cache Policy to whitelist the ab_variant cookie. Here is the Terraform snippet for the cache policy:

# terraform/cloudfront.tf
resource "aws_cloudfront_cache_policy" "nextjs_ab_testing" {
  name        = "Nextjs-AB-Testing-Policy"
  default_ttl = 86400
  max_ttl     = 31536000
  min_ttl     = 0

  parameters_in_cache_key_and_forwarded_to_origin {
    cookies_config {
      cookie_behavior = "whitelist"
      cookies {
        items = ["ab_variant"]
      }
    }

    headers_config {
      header_behavior = "whitelist"
      headers {
        items = ["Host", "x-forwarded-host"]
      }
    }

    query_strings_config {
      query_string_behavior = "all"
    }
  }
}

Phase 2: Next.js Middleware for Transparent Routing

When the normalized request reaches Next.js, the Next.js Middleware intercepts it. The middleware's job is to check for the ab_variant cookie. If it doesn't exist, the middleware assigns one based on a weighted distribution, appends a Set-Cookie header to the response, and rewrites the URL to the corresponding internal variant route.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const COOKIE_NAME = 'ab_variant';
const VARIANTS = ['control', 'treatment'];
const WEIGHTS = [0.5, 0.5]; // 50/50 split

function getVariantAssignment(): string {
  const rand = Math.random();
  let cumulative = 0;
  for (let i = 0; i < WEIGHTS.length; i++) {
    cumulative += WEIGHTS[i];
    if (rand <= cumulative) return VARIANTS[i];
  }
  return VARIANTS[0];
}

export function middleware(request: NextRequest) {
  // 1. Exclude static assets and API routes
  if (
    request.nextUrl.pathname.startsWith('/_next') ||
    request.nextUrl.pathname.startsWith('/api') ||
    request.nextUrl.pathname.includes('.')
  ) {
    return NextResponse.next();
  }

  let variant = request.cookies.get(COOKIE_NAME)?.value;
  let isNewAssignment = false;

  // 2. Assign variant if missing or invalid
  if (!variant || !VARIANTS.includes(variant)) {
    variant = getVariantAssignment();
    isNewAssignment = true;
  }

  // 3. Clone URL and rewrite to the specific variant route
  const url = request.nextUrl.clone();

  // Example: rewriting root '/' to '/control' or '/treatment'
  if (url.pathname === '/') {
    url.pathname = `/${variant}`;
  } else {
    return NextResponse.next();
  }

  const response = NextResponse.rewrite(url);

  // 4. Persist the assigned variant to the client
  if (isNewAssignment) {
    response.cookies.set(COOKIE_NAME, variant, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 60 * 60 * 24 * 30, // 30 days
    });
  }

  // Add an internal header so Server Components know which variant rendered
  response.headers.set('x-ab-variant', variant);

  // Crucial: Set Vary header to prevent Next.js from mixing up cached variants
  response.headers.set('Vary', 'Cookie');

  return response;
}

Warning: Always use NextResponse.rewrite(), not NextResponse.redirect(). A redirect changes the URL in the user's browser, hurting UX and SEO. A rewrite keeps the URL identical (e.g., yourapp.com/) while internally serving the /[variant] content.

Phase 3: Statically Generating the App Router Variants

We want the performance of Static Site Generation (SSG) with the dynamic capabilities of A/B testing. We achieve this by mapping the rewritten paths to a dynamic route segment in the App Router. Next.js ignores route groups (folders wrapped in parentheses) in the URL path, allowing us to neatly organize our variant pages.

Structure your Next.js app directory like this:

app/
 β”œβ”€β”€ (variants)/
 β”‚    └── [variant]/
 β”‚         β”œβ”€β”€ page.tsx
 β”‚         └── layout.tsx
 β”œβ”€β”€ layout.tsx
 └── middleware.ts

In app/(variants)/[variant]/page.tsx, use generateStaticParams to tell Next.js to build both the control and treatment pages as static HTML at build time.

// app/(variants)/[variant]/page.tsx
import { notFound } from 'next/navigation';
import HeroControl from '@/components/HeroControl';
import HeroTreatment from '@/components/HeroTreatment';

interface PageProps {
  // Using Promise to conform to standard Next.js 15+ patterns
  params: Promise<{ variant: string }>;
}

// Statically generate both variants at build time
export function generateStaticParams() {
  return [{ variant: 'control' }, { variant: 'treatment' }];
}

export default async function LandingPage({ params }: PageProps) {
  const { variant } = await params;

  if (variant !== 'control' && variant !== 'treatment') {
    notFound();
  }

  return (
    <main className="flex min-h-screen flex-col items-center">
      {/* Zero flicker, fully server-rendered HTML */}
      {variant === 'treatment' ? <HeroTreatment /> : <HeroControl />}

      <section className="mt-10">
        <h2>Shared Content Below the Fold</h2>
        <p>This content remains the same across both variants.</p>
      </section>
    </main>
  );
}

Because CloudFront caches the response against the ab_variant cookie, users who are assigned "treatment" hit the CDN, the CDN recognizes the cookie, and serves the statically generated "treatment" HTML in < 50ms. No hydration mismatches, no CLS, pure performance.

Handling Edge Cases: Bot Traffic and Cache Invalidation

Deploying edge personalization introduces unique failure modes that can cripple your SEO or poison your caches if left unchecked.

1. Bot Detection

Search engine crawlers (like Googlebot) do not maintain cookie state across sessions. If you assign variants randomly to bots, Google will index different versions of your page arbitrarily, triggering cloaking penalties or diluting your keyword density.

You must bypass A/B testing logic for known bot user agents in your middleware:

// Add this import to middleware.ts
import { userAgent } from 'next/server';

function isBotRequest(request: NextRequest): boolean {
  const { isBot } = userAgent(request);
  return isBot;
}

// Inside your middleware function, before variant assignment:
if (isBotRequest(request)) {
  // Force control variant for all crawlers to ensure consistent SEO
  const url = request.nextUrl.clone();
  url.pathname = '/control';
  return NextResponse.rewrite(url);
}

2. Analytics Integration

A common issue with server-side A/B testing is marrying the assigned variant to your client-side analytics (e.g., Google Analytics 4, Mixpanel, Segment). Because the variant is assigned on the server, your client-side tracking scripts need a way to read it.

Avoid relying on document.cookie parsing on the client. Instead, inject the variant into a strict Client Component that fires on mount:

// components/AnalyticsTracker.tsx
'use client';

import { useEffect } from 'react';

export default function AnalyticsTracker({ variant }: { variant: string }) {
  useEffect(() => {
    // Example: Push to dataLayer for Google Tag Manager
    const dataLayer = (window as any).dataLayer || [];
    dataLayer.push({
      event: 'ab_test_exposure',
      test_name: 'hero_conversion_test',
      variant_assigned: variant,
    });
  }, [variant]);

  return null; // Renderless component
}

Include <AnalyticsTracker variant={variant} /> inside your app/(variants)/[variant]/page.tsx file to seamlessly bridge server-assigned variants with client-side tracking.

3. Avoiding Cache Poisoning

If you rely solely on Next.js for caching without strict CloudFront cache policies, you risk "cache poisoning," where the CDN caches the control HTML and serves it to a treatment user.

As demonstrated in the middleware snippet above, ensuring your Next.js application emits the correct Vary: Cookie header acts as a robust secondary safeguard alongside CloudFront's Cache Policy.

By orchestrating CloudFront Functions to normalize cache keys and Next.js Middleware to handle the routing logic, you bridge the gap between dynamic personalization and static performance. Your users get instant, tailored content, and your infrastructure remains horizontally scalable.

Available for Work

Need help building this in production?

SoftwareCrafting is a full-stack dev agency β€” we ship fast, scalable React, Next.js, Node.js, React Native & Flutter apps for global clients.

Get a Free Consultation

Frequently Asked Questions

Why does client-side A/B testing cause UI flickering in Next.js?

Client-side A/B testing forces the browser to download the JavaScript bundle, boot React, and evaluate feature flags before rendering the correct variant. This delay results in either a blank loading skeleton or a visible flash from the control variant to the treatment variant, which severely degrades your Cumulative Layout Shift (CLS) and user experience.

How can I prevent A/B testing from destroying my CDN cache hit rate?

Forwarding all user cookies to your Next.js origin fragments your cache because tracking cookies (like Google Analytics) are unique to every user. You can solve this by using an AWS CloudFront Function on the Viewer Request to strip out all cookies except your specific A/B variant cookie. If your team needs help implementing this edge architecture, our SoftwareCrafting full-stack web development services specialize in building high-performance, cache-optimized platforms.

Why should I use CloudFront Functions instead of Lambda@Edge for request routing?

CloudFront Functions execute in under one millisecond directly at the Edge PoP, making them significantly faster for cache normalization than Lambda@Edge. They are also much more cost-effective, costing around $0.10 per million invocations compared to Lambda@Edge's $0.60+ base rate plus duration charges.

How does Next.js Middleware handle the A/B test variant assignment?

Next.js Middleware intercepts the incoming request from CloudFront and checks for an existing A/B test cookie. If the cookie is missing, the middleware assigns a variant, appends a Set-Cookie header to the response, and transparently rewrites the request URL to the corresponding variant route.

Can I use statically generated pages (SSG) with edge-based A/B testing?

Yes, you can use generateStaticParams in the Next.js App Router to statically generate all of your test variants at build time. The Next.js Middleware simply rewrites the user's request to the correct pre-rendered static route based on their edge-assigned cookie. If you need assistance optimizing your infrastructure for this setup, explore the architecture consulting pricing and solutions offered by SoftwareCrafting.

πŸ“Ž Full Code on GitHub Gist: The complete HeroSection.tsx from this post is available as a standalone GitHub Gist β€” copy, fork, or embed it directly.

About the author

SoftwareCrafting

This article was published by SoftwareCrafting engineers for founders, product teams, and developers working on real production delivery. We focus on practical tradeoffs, maintainable architecture, and implementation details that hold up outside demos.

View author profile

Last updated: 2026-04-06