Getting started

React Router

Start a React Router (framework mode) site on a space with npx @obelism/content init, load every page by path, edit it live in the canvas and on the site, and deploy it to Shopify Oxygen.

6 min read

This sets up a React Router v7 site in framework mode (Remix's successor) that renders every page entry at its path on the server, shows drafts in the canvas, and lets editors sign in on the site. It runs on any host that runs a Worker or a Node server; Shopify Oxygen has what changes on Shopify's. The SDK's examples/content-react-router-oxygen is the same setup on Oxygen, deployed.

1. Start the repository on a space

Create a management token under Settings → API tokens, then run in the site's root:

export CONTENT_MANAGEMENT_TOKEN=cms_m_…
npx @obelism/content init
npm i @obelism/content-react

init writes the model file (content/content-model.json), an example of what the delivery API answers for every content type (content/examples/), installs @obelism/content, and adds the token variables to .env.example. Put a delivery token in .env (never commit it):

CONTENT_DELIVERY_TOKEN=cms_d_…
# The space and environment it reads (Space settings), so the canvas's
# token for another space reads nothing here.
CONTENT_SPACE_ID=space_…
CONTENT_ENVIRONMENT=production

On Shopify Oxygen these arrive in the Worker's env binding instead of process.env: Shopify Oxygen shows how to read them.

2. The clients

One client for published content, and one per request for drafts: the canvas's cms_token, then the site token of an editor who signed in on the site, then the published client. This module only runs in loaders, on the server.

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

export const delivery = new ContentClient({
	token: process.env.CONTENT_DELIVERY_TOKEN!,
})

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

export const contentFor = (request: Request) => {
	// `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)
	const siteToken = readSiteToken(request.headers.get('cookie'))
	if (siteToken) return drafts(siteToken)
	return delivery
}

These reads are uncached. Cache published ones for a short while where the host has a cache; Oxygen's way is in its caching step.

3. Every page by path

One splat route serves every path:

app/routes.ts
import { type RouteConfig, route } from '@react-router/dev/routes'

export default [
	route('cms/*', 'routes/cms.tsx'),
	route('*', 'routes/page.tsx'),
] satisfies RouteConfig

The loader fetches the page; the component renders it with useLiveEntry, which returns the editor's unsaved version while the page is open in the canvas, and the loaded entry everywhere else. editable marks the entry and its blocks, so editors can click them.

app/routes/page.tsx
import { ContentApiError } from '@obelism/content'
import {
	type Block,
	editable,
	type Entry,
	RichText,
	type RichTextNode,
	useLiveEntry,
} from '@obelism/content-react'
import { data } from 'react-router'

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

type HeroBlock = Block<{ heading: string | null }, 'hero'>
type TextBlock = Block<{ text: RichTextNode | null }, 'text'>
type PageEntry = Entry<
	{ title: string | null; body: (HeroBlock | TextBlock)[] | null },
	'page'
>

export async function loader({ params, request }: Route.LoaderArgs) {
	const path = params['*'] || 'home'
	const entry = await contentFor(request)
		.getPage<PageEntry>(path, { depth: 2 })
		.catch((error: unknown) => {
			// A made-up, expired or other space's `cms_token`: show what's published.
			if (error instanceof ContentApiError && error.status === 401) {
				return delivery.getPage<PageEntry>(path, { depth: 2 })
			}
			throw error
		})
	if (!entry) throw data(null, { status: 404 })
	return { entry }
}

export const meta = ({ data }: Route.MetaArgs) => [
	{ title: data?.entry.fields.title ?? '' },
]

export default function Page({ loaderData }: Route.ComponentProps) {
	const entry = loaderData.entry
	const { fields } = useLiveEntry(entry)

	return (
		<main {...editable(entry)}>
			<h1>{fields.title}</h1>
			{fields.body?.map((block) =>
				block._type === 'hero' ? (
					<section key={block._uid} {...editable(block)}>
						<h2>{block.heading}</h2>
					</section>
				) : (
					<section key={block._uid} {...editable(block)}>
						<RichText document={block.text} />
					</section>
				),
			)}
		</main>
	)
}

content/examples/ shows what each of your content types looks like; adjust PageEntry to your own model.

4. Start the bridge

app/root.tsx
import { ContentBridge } from '@obelism/content-react'
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'

export function Layout({ children }: { children: React.ReactNode }) {
	return (
		<html lang="en">
			<head>
				<Meta />
				<Links />
			</head>
			<body>
				<ContentBridge />
				{children}
				<ScrollRestoration />
				<Scripts />
			</body>
		</html>
	)
}

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

It does nothing outside the editor.

5. Let editors sign in on the site

Editors can work on the site itself, at /cms/login: drafts, and the prompt bar to change the page by prompt or form. One resource route serves all of it, with the same handler Next.js mounts:

app/routes/cms.tsx
import { createSiteEditing, type SiteEnvironment } from '@obelism/content/site'

import type { Route } from './+types/cms'

const handler = createSiteEditing({
	spaceId: process.env.CONTENT_SPACE_ID!,
	environment:
		(process.env.CONTENT_ENVIRONMENT as SiteEnvironment | undefined) ??
		'production',
})

export const loader = ({ request }: Route.LoaderArgs) => handler(request)
export const action = ({ request }: Route.ActionArgs) => handler(request)

Return its Response as it is: it sets several cookies, and rebuilding the response loses them. React Router has no draft mode, so contentFor above reads the site token from the cookie on every request; answer draft responses with Cache-Control: private, no-store and X-Robots-Tag: noindex.

On Oxygen, process.env doesn't exist: read spaceId and environment with context.get(envContext) in loader and action, as the clients above do. A route's headers export gets no request, so decide the draft headers in a root middleware that sees the cms_token or the site token, not in headers. The exit link goes to /cms/logout as a plain <a>, or with prefetch="none": a prefetch would end the session before anyone clicks. Editing on your site has the flow and what editors get.

6. Let the CMS frame the site

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",
	}
}

frame-ancestors lets the canvas show the site, frame-src lets the prompt bar's form open. React Router sends the headers of the deepest matching route, so a child route that exports its own headers has to merge parentHeaders to keep this: set it here, or in entry.server.tsx. If your site sends a stricter policy, add both directives to it, and drop X-Frame-Options: DENY for preview requests.

Then set the environment's preview URL (Settings) to where the site runs, such as http://localhost:5173, and open the Canvas.

Next steps

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?