Step 8 of 11 (73% complete)
Experiment Pages
Step Code
The code for this specific step can be found on the following branch:
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!