# React Router (/docs/getting-started/react-router)

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](/docs/getting-started/nextjs) is middleware-based and doesn't apply.

This page assumes React Router 7 in framework mode, with the [React SDK](/docs/getting-started/react) already mounted for the browser half.

## 1. One server SDK per isolate [#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.

```ts title="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!,
	}))
```

```ts title="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](/docs/api#without-a-stored-token) 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 [#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.

```ts title="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 [#3-render-it]

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

```tsx title="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](/docs/getting-started/react), 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.

```tsx title="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 [#5-allow-the-storefronts-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 [#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.
