# Shopify Oxygen (/docs/getting-started/oxygen)

[Oxygen](https://shopify.dev/docs/storefronts/hydrogen/deploy/oxygen) is Shopify's host for Hydrogen storefronts: a React Router 7 app as a module Worker on `workerd`, with its environments, previews and variables in the Shopify admin. A site there reads Content over HTTPS like any other site, but a few things differ from Vercel and from a Node server.

Start with the [React Router quick start](/docs/getting-started/react-router); this page is what changes when that site runs on Oxygen.

A worked example is in the SDK: [`examples/content-react-router-oxygen`](https://github.com/Obelism/sdk/tree/main/examples/content-react-router-oxygen). It runs every step below, with the Content model, the assets and the Improve test the checklist needs.

## 1. Store the token in the environment [#1-store-the-token-in-the-environment]

Oxygen gives the Worker an `env` argument instead of `process.env`, and issues no OIDC token of its own, so a [connected deployment](/docs/guides/api-tokens#without-a-stored-token) can't cover it: the site reads with a stored token.

Create a **delivery token** under **Settings → API tokens** of the environment the site shows, then add the three variables under **Storefront settings → Environments and variables** in the Shopify admin, the token marked secret. **Copy as .env** next to the token list copies the same names:

| Variable                 | Value                                                  |
| ------------------------ | ------------------------------------------------------ |
| `CONTENT_DELIVERY_TOKEN` | `cms_d_…`                                              |
| `CONTENT_SPACE_ID`       | `space_…`                                              |
| `CONTENT_ENVIRONMENT`    | `production`, or the environment this deployment shows |

A deployment keeps the variables it was deployed with, so a new or rotated token reaches the site only after a redeploy. A **preview token** (`cms_p_…`) goes in the same place, for a deployment that always shows drafts.

Shopify's React Router template already puts the Worker's `env` in the route context (`app/lib/env.ts`, set in `server.ts`). Add the three fields to its `Env` type, then read them with `context.get(envContext)`:

```ts title="app/lib/env.ts"
export type Env = {
	// …
	CONTENT_DELIVERY_TOKEN?: string
	CONTENT_SPACE_ID?: string
	CONTENT_ENVIRONMENT?: string
}
```

```ts title="app/lib/content.server.ts"
import { ContentClient, readPreviewParams } from '@obelism/content'
import { readSiteToken } from '@obelism/content/site'

import type { Env } from './env'

// Naming the space and environment makes a token someone made for another
// space read nothing here.
const drafts = (token: string, env: Env) =>
	new ContentClient({
		token,
		space: env.CONTENT_SPACE_ID,
		environment: env.CONTENT_ENVIRONMENT,
		init: { cache: 'no-store' },
	})

export const createDelivery = (env: Env) =>
	new ContentClient({
		token: env.CONTENT_DELIVERY_TOKEN!,
		space: env.CONTENT_SPACE_ID,
		environment: env.CONTENT_ENVIRONMENT,
	})

export const contentFor = (request: Request, env: Env) => {
	// `cms_preview` alone proves nothing: anyone can add it to a URL.
	const preview = readPreviewParams(new URL(request.url))
	if (preview?.token) return drafts(preview.token, env)
	const siteToken = readSiteToken(request.headers.get('cookie'))
	if (siteToken) return drafts(siteToken, env)
	return createDelivery(env)
}
```

```tsx title="app/routes/page.tsx"
import { data } from 'react-router'

import { contentFor } from '../lib/content.server'
import { envContext } from '../lib/env'
import type { Route } from './+types/page'

export async function loader({ context, params, request }: Route.LoaderArgs) {
	const env = context.get(envContext)
	const entry = await contentFor(request, env).getPage<PageEntry>(
		params['*'] || 'home',
		{ depth: 2 },
	)
	if (!entry) throw data(null, { status: 404 })
	return { entry }
}
```

The three clients are the [one-client-per-request](/docs/guides/preview#one-client-per-request) order from the quick start, with the variables read from the binding instead of `process.env`, and the quick start's `401` fallback: an expired site token, a `cms_token` from another space, or a deployment that names another environment than its token belongs to all show the published page.

## 2. The preview environment must be public [#2-the-preview-environment-must-be-public]

The [canvas](/docs/guides/canvas) frames your site in the editor's browser, and that browser is signed in to Obelism Content, not to Shopify. A private Oxygen environment asks for a Shopify staff login on every request, so the frame shows that login page instead of your site: the environment whose **preview URL** the canvas opens has to be **public**.

Oxygen allows one public environment on the single-store plans, 25 on Plus, and none on development stores and trial plans. A development store gets none even when its plan shows Plus: the store type is checked as well as the plan. Two ways to fit:

- **Plus**: production public, and a second public environment on its own branch for editors. Point each Content environment's preview URL at its Oxygen environment URL.
- **Otherwise**: set production's preview URL to the one public environment and let the canvas show drafts there through `cms_token`; visitors without a token still get published content. [Editing on the site](/docs/guides/site-editing) works there too.

Point the preview URL at an **environment** URL, not at a deployment's own preview URL: that one changes with every deploy.

## 3. Send the headers the editor needs [#3-send-the-headers-the-editor-needs]

The shortest policy that works for Content: the canvas frames the site (`frame-ancestors`), the prompt bar's form is an iframe of the CMS (`frame-src`), and Content's assets come from its own host (`img-src`, and `connect-src` if a browser reads the delivery API).

```tsx title="app/root.tsx"
import type { Route } from './+types/root'

export function headers(_: Route.HeadersArgs) {
	return {
		'Content-Security-Policy':
			"frame-ancestors 'self' https://content.obelism.studio; frame-src 'self' https://content.obelism.studio; img-src 'self' data: https://content.obelism.studio",
	}
}
```

React Router sends the headers of the deepest matching route, so a child route that exports its own `headers` has to merge `parentHeaders` or this one is gone: keep the CSP here, in the root route or in `entry.server.tsx`. If you already send a policy, add these directives to it rather than replacing it. Drop `X-Frame-Options: DENY` for preview requests, and leave analytics out when the URL carries `cms_token` (the last [rule for drafts](/docs/guides/preview#rules-for-drafts)); numbers' and improve's origins go in `connect-src` next to Content's.

## 4. Cache with a TTL, not with tags [#4-cache-with-a-ttl-not-with-tags]

Oxygen has no tag cache, no way to purge the cache of the live deployment, and a Cache API that is per data centre. That fits Content: it purges its own edge cache when something is published, so a read you don't cache is never stale, and the only staleness left is the one you add.

- **Cache published reads for a short while.** Hydrogen's `createFetchWithCache` (the 2026-10 API; earlier versions have `createWithCache`) and a custom strategy are made for this: pass it as the client's `fetch`, with the space and environment in the key (they travel in headers, so the URL alone doesn't identify the answer).
- **Never cache a draft.** Read drafts with `cache: 'no-store'`, and answer the request with `Cache-Control: private, no-store` and `X-Robots-Tag: noindex` (the [rules for drafts](/docs/guides/preview#rules-for-drafts)).
- **Keep drafts out of Oxygen's full-page cache.** A page sent with `Oxygen-Cache-Control` stays cached until its `max-age`, since that cache can't be purged.

```ts title="app/lib/content.server.ts"
import {
	Cache,
	type CacheInstance,
	createFetchWithCache,
} from '@shopify/hydrogen'
import { ContentClient } from '@obelism/content'

import type { Env } from './env'

// The createDelivery above, with a cache in front of the published reads.
export const createCachedDelivery = (
	env: Env,
	cache: CacheInstance,
	waitUntil: ExecutionContext['waitUntil'],
) => {
	const fetchWithCache = createFetchWithCache({ cache, waitUntil })
	const key = (input: Parameters<typeof fetch>[0]) =>
		`${env.CONTENT_SPACE_ID}:${env.CONTENT_ENVIRONMENT}:${
			input instanceof Request ? input.url : String(input)
		}`

	return new ContentClient({
		token: env.CONTENT_DELIVERY_TOKEN!,
		space: env.CONTENT_SPACE_ID,
		environment: env.CONTENT_ENVIRONMENT,
		// A draft client passes `init.cache: 'no-store'` and skips this.
		fetch: (input, init) =>
			init?.cache === 'no-store'
				? fetch(input, init)
				: fetchWithCache(input, init, {
						key: key(input),
						strategy: Cache({ maxAge: 60, staleWhileRevalidate: 600 }),
					}),
	})
}
```

Use it where `contentFor` uses `createDelivery(env)`, with `context.cache` and `context.waitUntil`: the Worker entry that Shopify's template ships sets both on the route context.

## 5. Know what a webhook can't do [#5-know-what-a-webhook-cant-do]

A [webhook](/docs/guides/webhooks) still reaches the site when something is published, but on Oxygen it can't revalidate by tag: it can warm a page, or delete one key in the data centre that happens to receive it, not everywhere. With the TTL above, most sites don't need more; a site that must show a publish at once either keeps its TTLs very short or rebuilds on the webhook.

## Checklist [#checklist]

- [ ] `CONTENT_DELIVERY_TOKEN`, `CONTENT_SPACE_ID` and `CONTENT_ENVIRONMENT` in the storefront's variables, the token secret.
- [ ] The environment the deployment shows is the one `CONTENT_ENVIRONMENT` names.
- [ ] The preview URL is the deployment's Oxygen **environment** URL, and that environment is public.
- [ ] `frame-ancestors` and `frame-src` allow `https://content.obelism.studio`.
- [ ] Published reads cached for seconds to a minute; drafts uncached, `private, no-store` and `noindex`.
- [ ] A redeploy after rotating the token.
- [ ] No `process.env`, `Buffer` or `node:crypto` in server code: the `env` binding, Web APIs, and `headers.getSetCookie()` for several `Set-Cookie` values.
