Server-side tests on static pages
Our recommended setup for a server-side A/B test on a page you can prerender. Build every version as a static page, keep control on the original URL, and let edge middleware pick which file to serve.
This is how we run the hero test on our own homepage, and the setup we recommend for any page you can prerender: a marketing page, a landing page, a pricing page.
You get the benefits of a server-side test without paying for a render on every request:
- No layout shift. The first paint is already the visitor's variant.
- No render per request. Each version is built once. A visit costs one static file read.
- Safe to remove. Control stays a normal page on the original URL, so the page keeps working without the test.
How it works
- Every version is its own prerendered page. Control stays on the original URL (
/), and each variation gets a path of its own (/variation). - Middleware runs before the static file is served, for the tested URL only.
- It decides the variant with the
ImproveServerSDK, reusing the visitor's cookies when they are valid. - It fetches the matching file from your static assets and returns it under the original URL, with the cookies that keep the visitor in their variant. The address bar never changes.
GET / ─▶ middleware ─▶ control ─▶ serve / (static)
└▶ variation ─▶ serve /variation (static)Why control stays on the original URL
You could put control on its own path (/control) and have no page at /. We recommend against it. With control at /:
- If the middleware throws, is misconfigured or is removed,
/still serves a real page instead of a 404. - Ending the test means deleting the middleware. Nothing needs to move.
- Bots and anything else you leave out of the test simply get the page at
/.
The cost is one setting on hosts that serve static files before your code runs. See step 2.
1. Build both versions as static pages
Render the same page component twice with a different variant prop, and prerender both routes. In Next.js that is two routes without dynamic data. In Waku it is getConfig returning render: 'static'.
export default function Home() {
return <HomePage variant="control" />
}export default function Home() {
return <HomePage variant="variation" />
}Point both versions' canonical URL at / (<link rel="canonical" href="https://example.com/">) so search engines index one page, and leave /variation out of your sitemap.
2. Let the middleware see the request first
Many hosts serve a matching static file before any of your code runs. For / that would skip the test, because control is a static file at /. Tell the host to run your code first for that one path.
On Cloudflare Workers, set run_worker_first in your Wrangler config:
{
"assets": {
"binding": "ASSETS",
"directory": "./dist/public",
"run_worker_first": ["/"],
},
}Keep it to the tested paths. Everything else should go straight to static assets. Next.js middleware and Netlify Edge Functions already run before static files, so they need no extra setting.
3. Decide the variant and serve the file
This is a Cloudflare Worker, but the steps work in any edge runtime that can read a static file and set headers.
import { ImproveServerSDK } from '@obelism/improve-sdk/server'
type Env = { ASSETS: Fetcher; IMPROVE_TOKEN: string }
const TEST_SLUG = 'startpage-visual'
const VARIANT_PATHS: Record<string, string> = {
control: '/',
variation: '/variation',
}
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const BOT_REGEX = /bot|crawl|spider|slurp|facebookexternalhit|preview/i
let improveSdk: ImproveServerSDK | undefined
const getCookie = (request: Request, name: string) =>
request.headers
.get('Cookie')
?.split(/;\s*/)
.find((cookie) => cookie.startsWith(`${name}=`))
?.slice(name.length + 1)
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url)
const isRead = request.method === 'GET' || request.method === 'HEAD'
if (url.pathname !== '/' || !isRead) return env.ASSETS.fetch(request)
// Leave bots out of the test: they get control and no cookies.
const userAgent = request.headers.get('User-Agent') ?? ''
if (BOT_REGEX.test(userAgent)) return env.ASSETS.fetch(request)
improveSdk ??= new ImproveServerSDK({
organizationId: 'org_MJFL46Z0WXGQ5OHW1ZXSM3Q88S',
environment: 'production',
token: env.IMPROVE_TOKEN,
})
await improveSdk.fetchConfig()
// Reuse the visitor's id and variant when the cookies are still valid.
const visitorCookieName = improveSdk.getVisitorCookieName()
const cookieVisitorId = getCookie(request, visitorCookieName)
const visitorId =
cookieVisitorId && improveSdk.validateVisitorId(cookieVisitorId)
? cookieVisitorId
: improveSdk.generateVisitorId()
const cookieValue = getCookie(request, TEST_SLUG)
const country = request.headers.get('CF-IPCountry') ?? undefined
const testValue =
cookieValue && improveSdk.validateTestValue(TEST_SLUG, cookieValue)
? cookieValue
: improveSdk.getTestValue(TEST_SLUG, visitorId, userAgent, {
country,
})
// Fetch the chosen version. Forwarding the headers keeps conditional
// requests working: each file has its own ETag, so a visitor whose
// variant changed never gets a 304 for the wrong one.
const path = VARIANT_PATHS[testValue ?? 'control'] ?? '/'
const asset = await env.ASSETS.fetch(
new Request(new URL(path, url), {
method: request.method,
headers: request.headers,
}),
)
const response = new Response(asset.body, asset)
response.headers.set('Cache-Control', 'private, max-age=0, must-revalidate')
if (testValue) {
const options = `Path=/; Max-Age=${COOKIE_MAX_AGE}`
response.headers.append(
'Set-Cookie',
`${visitorCookieName}=${visitorId}; ${options}`,
)
response.headers.append(
'Set-Cookie',
`${TEST_SLUG}=${testValue}; ${options}`,
)
}
return response
},
}env.ASSETS.fetch reads the static file directly. It does not go through run_worker_first again, so fetching / from inside the Worker returns control rather than calling the Worker a second time.
The country argument lets audiences that target a country resolve on the server, which the browser can't do.
Caching
One URL now serves two versions, decided by a cookie. Keep the response out of shared caches:
privatestops a CDN from storing one visitor's variant and serving it to everyone else at that location.max-age=0, must-revalidatestill lets the browser keep the page for back/forward navigation and revalidate it cheaply with the ETag.
Don't use no-store here. It removes the page from the browser's back/forward cache and gains nothing.
4. Record the exposure in the browser
Results are attributed to the variant a visitor was exposed to. The server decided the variant, so read it once on the client to record that exposure. Both the client getTestValue and the React useTestValue trust a valid assignment cookie instead of bucketing again, so they return the variant the middleware chose.
const variant = useTestValue('startpage-visual')Then post your events with usePostAnalytic as usual. See Events.
Things to watch
- Link to the tested URL with a plain
<a href="/">. A client-side router (Next.js<Link>, Waku<Link>) fetches the next page as data, which can skip the middleware and always show control. A full page load always goes through the test. - Test a variant by setting its cookie (
startpage-visual=variation) in your browser's dev tools. For quick checks you can also have the middleware accept a query parameter with the same name before it reads the cookie. - Each variation needs its own build output, so this setup fits a handful of variants. For many combinations, render on the server instead. See Multi-variant tests.
- Ending the test: ship the winning version at
/, then delete the middleware and therun_worker_firstentry. Visitors who still have the cookie simply get the page at/.