Getting started

React Router

Decide the variant in a React Router loader on Shopify Oxygen, keep it in cookies, and let the React provider record the exposure. No layout shift.

5 min read

A React Router loader runs before the page renders, which is where a server-side variant belongs: the first paint already has the visitor's version. On Shopify Oxygen there is no middleware helper to install, so the loader is the hook; the Next.js setup is middleware-based and doesn't apply.

This page assumes React Router 7 in framework mode, with the React SDK already mounted for the browser half.

1. One server SDK per isolate

A Worker isolate serves one deployment of one storefront, so build the server SDK once and let it keep the datafile in memory between requests.

app/lib/improve.server.ts
import { ImproveServerSDK } from '@obelism/improve-sdk/server'

import type { Env } from './env'

let sdk: ImproveServerSDK | undefined

export const getImprove = (env: Env) =>
	(sdk ??= new ImproveServerSDK({
		organizationId: env.IMPROVE_ORGANIZATION_ID!,
		// Only when the test lives in another space than the organization's first.
		spaceId: env.IMPROVE_SPACE_ID,
		environment: env.IMPROVE_ENVIRONMENT,
		// `imp_…` from Settings → API tokens. Without one, the datafile is
		// rate-limited per IP, and Oxygen's egress IPs are shared.
		token: env.IMPROVE_API_TOKEN!,
	}))
app/lib/env.ts
export type Env = {
	IMPROVE_ORGANIZATION_ID?: string
	IMPROVE_SPACE_ID?: string
	IMPROVE_ENVIRONMENT?: 'develop' | 'staging' | 'production'
	IMPROVE_API_TOKEN?: string
}

Shopify's React Router template already exports envContext from app/lib/env.ts and sets it in server.ts, so add the fields to its type and read them with context.get(envContext).

The isolate keeps the first datafile it fetched: fetchConfig() returns at once once it has one, so a test created or changed after the isolate started reaches it when the isolate restarts, or through a deploy. For a faster pickup, fetch the datafile yourself on a schedule and pass it with loadConfig(config).

Add IMPROVE_ORGANIZATION_ID, IMPROVE_ENVIRONMENT and IMPROVE_API_TOKEN to the storefront's variables, plus IMPROVE_SPACE_ID when the tests live in another space than the organization's first; Copy as .env under Settings copies the names (the token line stays empty), and the token is shown once when you create it. Oxygen issues no OIDC token, so a connected deployment can't cover it, and a deployment keeps the variables it was deployed with: a new token needs a redeploy.

2. Decide in the loader

Read the visitor and the stored variant from the cookies, decide only when there is nothing to reuse, and hand back the cookies to set.

app/lib/improve.server.ts
const readCookie = (header: string | null, name: string) =>
	header
		?.split('; ')
		.find((row) => row.split('=')[0] === name)
		?.split('=')[1] ?? null

const cookie = (name: string, value: string) =>
	`${name}=${value}; Path=/; Max-Age=2592000; SameSite=Lax; Secure`

// Oxygen sets `oxygen-buyer-country`; other hosts use their own header.
const countryOf = (request: Request) =>
	request.headers.get('oxygen-buyer-country') ??
	request.headers.get('cf-ipcountry') ??
	request.headers.get('x-vercel-ip-country') ??
	undefined

export type Variant = { value: string; cookies: string[] }

export async function variant(
	request: Request,
	env: Env,
	testSlug: string,
): Promise<Variant> {
	const improve = getImprove(env)
	await improve.fetchConfig()

	const test = improve.getTestConfig(testSlug)
	const visitorCookie = improve.getVisitorCookieName()
	const storedVisitor = readCookie(request.headers.get('cookie'), visitorCookie)
	const visitorId =
		storedVisitor && improve.validateVisitorId(storedVisitor)
			? storedVisitor
			: improve.generateVisitorId()

	const stored = readCookie(request.headers.get('cookie'), testSlug)
	const known =
		stored && test?.options.some((option) => option.slug === stored)
			? stored
			: null

	const value =
		known ??
		improve.getTestValue(
			testSlug,
			visitorId,
			request.headers.get('user-agent') ?? '',
			{ country: countryOf(request) },
		) ??
		test?.defaultValue ??
		'control'

	return {
		value,
		cookies: [cookie(visitorCookie, visitorId), cookie(testSlug, value)],
	}
}

getTestValue answers null for a slug that isn't in the datafile, so the fallback keeps the page rendering with the control. The cookie is what makes the variant sticky across isolates: the in-memory cache only helps within one, and Oxygen runs many.

A flag is the same call: improve.getFlagValue(flagSlug, visitorId, userAgent, { country }).

3. Render it

app/routes/home.tsx
import { data } from 'react-router'

import { variant } from '../lib/improve.server'
import { envContext } from '../lib/env'
import type { Route } from './+types/home'

export async function loader({ request, context }: Route.LoaderArgs) {
	const env = context.get(envContext)
	const { value, cookies } = await variant(request, env, 'home-hero')

	const headers = new Headers()
	for (const setCookie of cookies) headers.append('Set-Cookie', setCookie)
	return data({ variant: value }, { headers })
}

export default function Home({ loaderData }: Route.ComponentProps) {
	return loaderData.variant === 'b' ? <HeroB /> : <HeroA />
}

React Router sends Set-Cookie from a loader on its own. Keep tested routes out of prerendering: a loader that runs at build time bakes in one variant for everyone.

4. Record the exposure in the browser

A server-side decision is not an exposure: the count comes from the browser reading the variant. Have the client read the same slug once, which it does from the cookie the loader set, without re-bucketing.

app/routes/home.tsx
import { useTestValue } from '../lib/improve.client'

const value = useTestValue('home-hero', loaderData.variant)

useTestValue returns null until the provider has loaded, so pass the loader's value as the fallback to keep the markup stable. useFlagValue does the same for flags, and usePostAnalytic() sends the events a test's conversion listens for.

The provider is the one from the React SDK, with the same organization and environment as the server SDK. That guide generates app/lib/improve.client.ts with generateImproveProvider; export its ImproveProvider, useTestValue and usePostAnalytic from there.

app/root.tsx
import { Outlet } from 'react-router'

import { ImproveProvider } from '../lib/improve.client'

export default function App() {
	return (
		<ImproveProvider>
			<Outlet />
		</ImproveProvider>
	)
}

5. Allow the storefront's origin

The datafile and the events reach Improve from the browser, so the storefront's origin has to be in the environment's allowed origins (Settings). Origins match exactly, or by port with :*: Oxygen gives every deployment its own preview URL, so preview deployments are new origins each time. Add the environment's own URL, or turn on allow any origin for develop and staging while you test; production should list its one domain.

A server-side fetchConfig sends the API token and no Origin, so it is not affected.

Checklist

  • IMPROVE_ORGANIZATION_ID, IMPROVE_ENVIRONMENT and IMPROVE_API_TOKEN in the storefront's variables.
  • The loader decides once and sets both cookies: visitorId and the test or flag slug.
  • The client reads the same slug, so exposures are recorded.
  • Country audiences get oxygen-buyer-country (or the host's own header).
  • The storefront's origin, and each preview origin you use, is allowed.
  • Tested routes aren't prerendered.

On this page

Marks in the margin

  • Note
  • Watch out
  • Updated

Scribes marked doubtful lines in the margin. That practice is called obelism.

Stuck on a step?