Getting started
Shopify Oxygen
Run a React Router site on Shopify's Oxygen with a stored delivery token, a public environment for the canvas, and a cache policy that fits the runtime.
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; this page is what changes when that site runs on Oxygen.
A worked example is in the SDK: 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
Oxygen gives the Worker an env argument instead of process.env, and issues no OIDC token of its own, so a connected deployment 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):
export type Env = {
// …
CONTENT_DELIVERY_TOKEN?: string
CONTENT_SPACE_ID?: string
CONTENT_ENVIRONMENT?: string
}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)
}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 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
The 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 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
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).
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); numbers' and improve's origins go in connect-src next to Content's.
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 havecreateWithCache) and a custom strategy are made for this: pass it as the client'sfetch, 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 withCache-Control: private, no-storeandX-Robots-Tag: noindex(the rules for drafts). - Keep drafts out of Oxygen's full-page cache. A page sent with
Oxygen-Cache-Controlstays cached until itsmax-age, since that cache can't be purged.
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
A webhook 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
-
CONTENT_DELIVERY_TOKEN,CONTENT_SPACE_IDandCONTENT_ENVIRONMENTin the storefront's variables, the token secret. - The environment the deployment shows is the one
CONTENT_ENVIRONMENTnames. - The preview URL is the deployment's Oxygen environment URL, and that environment is public.
-
frame-ancestorsandframe-srcallowhttps://content.obelism.studio. - Published reads cached for seconds to a minute; drafts uncached,
private, no-storeandnoindex. - A redeploy after rotating the token.
- No
process.env,Bufferornode:cryptoin server code: theenvbinding, Web APIs, andheaders.getSetCookie()for severalSet-Cookievalues.