# Server-side tests on static pages (/docs/guides/static-server-side-tests)



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](/docs/examples#server-side) 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 [#how-it-works]

1. **Every version is its own prerendered page.** Control stays on the original URL (`/`), and each variation gets a path of its own (`/variation`).
2. **Middleware runs before the static file is served**, for the tested URL only.
3. It **decides the variant** with the [`ImproveServerSDK`](/docs/sdk/javascript#server), reusing the visitor's cookies when they are valid.
4. 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.

```text
GET /  ─▶  middleware  ─▶  control    ─▶  serve /           (static)
                       └▶  variation  ─▶  serve /variation  (static)
```

### Why control stays on the original URL [#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](#2-let-the-middleware-see-the-request-first).

## 1. Build both versions as static pages [#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'`.

```tsx title="pages/index.tsx"
export default function Home() {
	return <HomePage variant="control" />
}
```

```tsx title="pages/variation.tsx"
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 [#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:

```jsonc title="wrangler.jsonc"
{
	"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 [#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.

```ts title="src/index.ts"
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](/docs/sdk/javascript#gettestvalue) that target a country resolve on the server, which the browser can't do.

### Caching [#caching]

One URL now serves two versions, decided by a cookie. Keep the response out of shared caches:

* `private` stops a CDN from storing one visitor's variant and serving it to everyone else at that location.
* `max-age=0, must-revalidate` still 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 [#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`](/docs/sdk/react#usetestvalue) trust a valid assignment cookie instead of bucketing again, so they return the variant the middleware chose.

```tsx
const variant = useTestValue('startpage-visual')
```

Then post your events with [`usePostAnalytic`](/docs/sdk/react#usepostanalytic) as usual. See [Events](/docs/guides/events).

## Things to watch [#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](/docs/guides/multi-variant-tests).
* **Ending the test**: ship the winning version at `/`, then delete the middleware and the `run_worker_first` entry. Visitors who still have the cookie simply get the page at `/`.
