Step 8 of 11 (73% complete)

Experiment Pages

Step Code

The code for this specific step can be found on the following branch:

Click on a link to view the code for this step on GitHub.

Defining Experiment Pages in Middleware

When introducing experimentation into a web application, it is crucial to avoid unnecessary performance degradation. Not all pages in the application are intended to run experiments - usually, only a subset of them are targeted for A/B testing or feature variation. If experimentation logic is applied globally, even static pages without experiments could be forced into dynamic rendering, losing the benefits of static site generation (SSG) and caching.

To address this, we define explicitly which pages should be treated as “experiment-enabled.” The remaining pages can safely remain statically generated and served from cache, preserving both performance and scalability.

There are two primary approaches to achieve this behavior:


1. Using an Environment Variable

The simplest method is to configure a new environment variable EXPERIMENT_PAGES.

  • This variable contains a comma-separated list of URL paths where experiments should run.

  • The middleware checks if the requested path is part of this list and dynamically rewrites it to an experimental route.

  • If the path is not listed, the request proceeds normally, allowing the page to remain cached and statically served.

This approach is flexible because updating the environment variable on platforms like Vercel does not require a full redeployment - the middleware dynamically picks up the new configuration.

Code Example:

const EXPERIMENT_PAGES = process.env.EXPERIMENT_PAGES
  ? new Set(process.env.EXPERIMENT_PAGES.split(",").map((path) => path.trim()))
  : new Set<string>()

function hasExperiments(pathname: string): boolean {
  // Remove locale from pathname for checking
  const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}(-[A-Z]{2})?/, "") || "/"

  // Check exact match first
  if (EXPERIMENT_PAGES.has(pathWithoutLocale)) {
    return true
  }

  // Check for dynamic routes (e.g., /product/[slug] matches /product/abc)
  for (const experimentPath of EXPERIMENT_PAGES) {
    if (experimentPath.includes("[") && experimentPath.includes("]")) {
      // Convert dynamic route to regex pattern
      const regexPattern = experimentPath
        .replace(/\[([^\]]+)\]/g, "([^/]+)") // Replace [slug] with ([^/]+)
        .replace(/\//g, "\\/") // Escape forward slashes

      const regex = new RegExp(`^${regexPattern}$`)
      if (regex.test(pathWithoutLocale)) {
        return true
      }
    }
  }

  return false
}


export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname
  let response = NextResponse.next()

  if (shouldExclude(pathname)) {
    return response
  }

  const localeInPathname = LOCALES.find((locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`)

  if (localeInPathname) {
    const pathnameWithoutLocale = pathname.replace(`/${localeInPathname}`, "")

    if (hasExperiments(pathname)) {
      const expUrl = createUrl(
        `/${localeInPathname}/exp${leadingSlashUrlPath(pathnameWithoutLocale)}`,
        request.nextUrl.searchParams,
      )
      response = NextResponse.rewrite(new URL(expUrl, request.url))
    } else {
      const newUrl = createUrl(
        `/${localeInPathname}${leadingSlashUrlPath(pathnameWithoutLocale)}`,
        request.nextUrl.searchParams,
      )
      response = NextResponse.rewrite(new URL(newUrl, request.url))
    }

    updateLocaleCookies(request, response, localeInPathname)
    return response
  }

  // Get locale with browser language preference
  const locale = getLocale(request, LOCALES)

  if (hasExperiments(`/${locale}${pathname}`)) {
    const expUrl = createUrl(`/${locale}/exp${leadingSlashUrlPath(pathname)}`, request.nextUrl.searchParams)
    response =
      locale === DEFAULT_LOCALE
        ? NextResponse.rewrite(new URL(expUrl, request.url))
        : NextResponse.redirect(new URL(expUrl, request.url))
  } else {
    const newUrl = createUrl(`/${locale}${leadingSlashUrlPath(pathname)}`, request.nextUrl.searchParams)
    response =
      locale === DEFAULT_LOCALE
        ? NextResponse.rewrite(new URL(newUrl, request.url))
        : NextResponse.redirect(new URL(newUrl, request.url))
  }

  updateLocaleCookies(request, response, locale)
  return response
}
 

Whenever hasExperiments() returns true, the middleware rewrites the request to a dedicated dynamic route (e.g., app/[locale]/exp/[slug]/page.tsx) which handles variations. This ensures that experimental pages are dynamic, while static pages remain unaffected.

// app/[locale]/exp/[slug]/page.tsx
import ContentAreaMapper from '@/components/content-area/mapper'
import VisualBuilderExperienceWrapper from '@/components/visual-builder/wrapper'
import { getCmsSaasContentVariation } from '@/flags'
import { optimizely } from '@/lib/optimizely/fetch'
import { SafeVisualBuilderExperience } from '@/lib/optimizely/types/experience'
import { getValidLocale } from '@/lib/optimizely/utils/language'
import { generateAlternates } from '@/lib/utils/metadata'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'

export async function generateMetadata(props: {
  params: Promise<{ locale: string; slug?: string }>
}): Promise<Metadata> {
  const { locale, slug = '' } = await props.params
  const locales = getValidLocale(locale)
  const formattedSlug = `/${slug}`
  const { data, errors } = await optimizely.getPageByURL({
    locales: [locales],
    slug: formattedSlug,
  })

  if (errors) {
    return {}
  }

  const page = data?.CMSPage?.item
  if (!page) {
    const experienceData = await optimizely.GetVisualBuilderBySlug({
      locales: [locales],
      slug: formattedSlug,
    })

    const experience = experienceData.data?.SEOExperience?.item

    if (experience) {
      return {
        title: experience?.title,
        description: experience?.shortDescription || '',
        keywords: experience?.keywords ?? '',
        alternates: generateAlternates(locale, formattedSlug),
      }
    }

    return {}
  }

  return {
    title: page.title,
    description: page.shortDescription || '',
    keywords: page.keywords ?? '',
    alternates: generateAlternates(locale, formattedSlug),
  }
}

export default async function CmsPage(props: {
  params: Promise<{ locale: string; slug?: string }>
}) {
  const { locale, slug = '' } = await props.params
  const locales = getValidLocale(locale)
  const formattedSlug = `/${slug}`
  const variationKey = await getCmsSaasContentVariation()

  const { data, errors } = await optimizely.getPageByURLWithVariation({
    locales: [locales],
    slug: formattedSlug,
    variationKey,
  })

  if (errors || !data?.CMSPage?.item?._modified) {
    const experienceData = await optimizely.GetVisualBuilderBySlugWithVariation(
      {
        locales: [locales],
        slug: formattedSlug,
        variationKey,
      }
    )

    const experience = experienceData.data?.SEOExperience?.item as
      | SafeVisualBuilderExperience
      | undefined

    if (experience) {
      return (
        <Suspense>
          <VisualBuilderExperienceWrapper experience={experience} />
        </Suspense>
      )
    }

    return notFound()
  }

  const page = data.CMSPage.item
  const blocks = (page?.blocks ?? []).filter(
    (block) => block !== null && block !== undefined
  )

  return (
    <>
      <Suspense>
        <ContentAreaMapper blocks={blocks} />
      </Suspense>
    </>
  )
}

GraphQL queries with variant content in the middle to retrieve a specific version of the variation from the Optimizely graph.

query getPageByURLWithVariation(
  $locales: [Locales]
  $slug: String
  $variationKey: String
) {
  CMSPage(
    variation: { include: SOME, value: [$variationKey], includeOriginal: true }
    locale: $locales
    where: { _metadata: { url: { default: { eq: $slug } } } }
  ) {
    item {
      title
      shortDescription
      keywords
      _modified
      blocks {
        ...ItemsInContentArea
      }
    }
  }
}
query GetVisualBuilderBySlugWithVariation(
  $locales: [Locales]
  $slug: String
  $variationKey: String
) {
  SEOExperience(
    variation: { include: SOME, value: [$variationKey], includeOriginal: true }
    locale: $locales
    where: { _metadata: { url: { default: { eq: $slug } } } }
  ) {
    item {
      title
      shortDescription
      keywords
      composition {
        nodes {
          nodeType
          key
          displaySettings {
            value
            key
          }
          ... on CompositionComponentNode {
            component {
              ...ItemsInContentArea
            }
          }
          ... on CompositionStructureNode {
            key
            rows: nodes {
              ... on CompositionStructureNode {
                key
                columns: nodes {
                  ... on CompositionStructureNode {
                    key
                    elements: nodes {
                      key
                      displaySettings {
                        value
                        key
                      }
                      ... on CompositionComponentNode {
                        component {
                          ...ItemsInContentArea
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

In const variationKey = await getCmsSaasContentVariation(), we obtain a variant from feature exp and then retrieve this content variant from cms, by passing it to graphql query as variable.

This method works well for projects with a small to moderate number of experimental pages, where maintaining a list of URLs in environment variables is manageable.

2. Using GraphQL to Fetch Experiment Pages

For larger sites, instead of managing experiment URLs via an environment variable, you can query Optimizely Graph to fetch all pages that have variations.

query getAllPagesWithVariations($pageType: [String]) {
  _Content(
    variation: { include: SOME, value: ["Variation1"] }
    where: { _metadata: { types: { in: $pageType } } }
  ) {
    items {
      _metadata {
        variation
        displayName
        url {
          base
          internal
          hierarchical
          default
          type
        }
        types
        status
      }
    }
  }
}

In this method:

  • The middleware checks against the list of experiment pages returned from the GraphQL query.

  • To avoid performance problems,** you can cache the GraphQL response with a revalidation interval** (e.g., every 1 hour or every 8 hours).

  • This makes it a more scalable solution for applications with a large number of pages.


Summary

  • Environment Variable → simple and effective for small sites with only a few experiment pages.

  • GraphQL Query → better suited for large-scale sites where manually managing experiment URLs is impractical.

Both approaches ensure that non-experiment pages remain fully static (SSG), while experiment-enabled pages can dynamically fetch and display the correct variation.

Have questions? I'm here to help!

Contact Me