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.
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.
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!,
}))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.
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
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.
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.
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_ENVIRONMENTandIMPROVE_API_TOKENin the storefront's variables. - The loader decides once and sets both cookies:
visitorIdand 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.