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

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](/docs/getting-started/oxygen) has what changes on Shopify's. The SDK's [`examples/content-react-router-oxygen`](https://github.com/Obelism/sdk/tree/main/examples/content-react-router-oxygen) is the same setup on Oxygen, deployed.

## 1. Start the repository on a space [#1-start-the-repository-on-a-space]

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

```sh
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):

```sh
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](/docs/getting-started/oxygen#1-store-the-token-in-the-environment) shows how to read them.

## 2. The clients [#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.

```ts title="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](/docs/getting-started/oxygen#4-cache-with-a-ttl-not-with-tags).

## 3. Every page by path [#3-every-page-by-path]

One splat route serves every path:

```ts title="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.

```tsx title="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 [#4-start-the-bridge]

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

```ts title="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](/docs/guides/site-editing) has the flow and what editors get.

## 6. Let the CMS frame the site [#6-let-the-cms-frame-the-site]

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

`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 [#next-steps]

- [Shopify Oxygen](/docs/getting-started/oxygen): a stored token, a public preview environment, and caching on Shopify's host.
- [Webhooks](/docs/guides/webhooks): purge your CDN or rebuild when something is published.
- [Previews and draft mode](/docs/guides/preview): the order the tokens are read in, and the rules that keep drafts off your public pages.
- [Paths per locale](/docs/sdk/javascript#paths-per-locale) and [sitemaps](/docs/sdk/javascript#sitemaps).
- [Images](/docs/api/images): resized and re-encoded copies with a query on the URL.
- [A/B tests in content](/docs/guides/ab-tests), with Obelism Improve.
