Guides · Your site

Previews and draft mode

How drafts reach your site, from the canvas's cms_token to editing on the site, preview tokens and connected deployments. Which to use, one client per request, and the rules that keep drafts off your public pages.

9 min read

A delivery token reads what is published. Everything else a site shows of a draft goes through a token that reads drafts, and there are four ways one reaches your server:

WayTokenReaches your server asLastsFor
CanvasCanvas, cms_v_…cms_token in the query of every frame's URL8 hoursEditors working in the dashboard
Editing on your siteSite, cms_s_…The httpOnly cookie obelism_content_token8 hours, or until sign-outEditors working on the site itself, with the prompt bar
Preview tokenPreview, cms_p_…Your server's environment variableUntil it expires or is revokedA staging or preview deployment that always shows drafts
Connected deploymentThe host's OIDC token, with drafts: trueAsked from Vercel or GitHub Actions per requestPer requestThe same, without a token to store

The first two act as the person editing, with their role at the time of each request: they stop working when that person leaves the organization. The last two show drafts to anyone who can open the deployment, so put that deployment behind your host's protection (Vercel's deployment protection, Cloudflare Access, basic auth).

Oxygen issues no OIDC token, so a storefront on Shopify Oxygen uses a stored token: a delivery token for its public pages, a preview token for a deployment that always shows drafts.

All four are refused by the delivery API from a browser on any origin but the environment's preview URL: read drafts on your server.

One client per request

Decide per request which token reads the page, in this order: the canvas's cms_token, then a signed-in editor's site token, then your delivery token. Everything a page reads (the page, its navigation, its settings) goes through that one client, so a draft page doesn't sit inside a published navigation.

lib/content.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 your 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) => {
	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
}

In Next.js the site token is read in draft mode, and the canvas's token from searchParams; the Next.js quick start has the same function written that way.

When the token is refused

A canvas or site token that expired, was revoked, belongs to another space, or was made up gets a 401. Show the published page then, and log why:

import { ContentApiError } from '@obelism/content'

const content = contentFor(request)
const page = await content.getPage(path).catch((error: unknown) => {
	if (error instanceof ContentApiError && error.status === 401) {
		console.warn('Content refused the draft token:', error.body)
		return delivery.getPage(path)
	}
	throw error
})

error.body says why: token_place_mismatch as its code for a token of another space or environment, otherwise a message such as Invalid or expired token (see the delivery API's errors). An editor who sees published content in the canvas, or on the site after a working day, has a token that ran out: opening the canvas again, or /cms/login, makes a new one.

Rules for drafts

  1. Decide by the token, never by cms_preview. The canvas adds cms_preview=1, cms_entry and cms_environment to its URLs, but anyone can type those into a browser. Read drafts only with the token the request brings (cms_token, the site cookie), never with a preview token of your own because a parameter is there. readPreviewParams answers { entryId, environment, token }; use token.
  2. Name your space and environment on every client that reads with a token from the request (space and environment on ContentClient, the Obelism-Space and Obelism-Environment headers without the SDK). Anyone with a space of their own can make a canvas token for it. Without these, such a token would put their drafts on your site; with them, it gets 401 with token_place_mismatch.
  3. Keep draft-reading tokens on your server. Preview tokens never go in client bundles or public environment variables (NEXT_PUBLIC_…, PUBLIC_…, VITE_…). The site token lives in an httpOnly cookie for this reason, and the prompt bar reaches Content through your server's /cms/api/….
  4. Never cache a draft. Fetch drafts with cache: 'no-store' (as above), and keep the page itself out of shared caches: a site token arrives on the same URL as the public page, so a CDN that caches by URL would hand an editor's draft to the next visitor. Answer draft requests with Cache-Control: private, no-store. Next.js does this itself in draft mode; elsewhere, set it wherever you pick the drafts client.
  5. Keep drafts out of search engines and analytics. Send X-Robots-Tag: noindex with draft responses. The canvas's token is in the frame's URL, and analytics tools record full URLs: leave your analytics out when readPreviewParams answers, so neither the token nor editors' visits reach them.

Live updates

A token shows what is saved. The canvas and the prompt bar also send what an editor types before it is saved, through the bridge:

  • React: useLiveEntry(entry) returns the unsaved version while editing, and entry everywhere else. See the React SDK.
  • Anything else: bridge.on('update', ({ entry }) => render(entry)), with entry in exactly the shape the delivery API answers for a draft token.

Without an update listener, the page reloads after every save, which shows the saved draft through the token.

A preview deployment

A staging site, or a preview deployment per pull request, can show drafts to everyone who opens it, with no sign-in: give that deployment a preview token instead of a delivery token, or, on Vercel or GitHub Actions, a connected deployment whose rule reads drafts:

import { ContentClient } from '@obelism/content'
import { vercelOidc } from '@obelism/content/oidc'

export const delivery = new ContentClient({
	token: vercelOidc(),
	space: process.env.CONTENT_SPACE_ID,
	environment: process.env.CONTENT_ENVIRONMENT,
	drafts: process.env.VERCEL_ENV === 'preview',
	init: { cache: 'no-store' },
})
  • Point the deployment at the environment it shows. A staging deployment reads staging, with staging's token: tokens belong to one environment.
  • Draft responses are never cached by Content, so such a deployment renders every page on request. Turn its own caches off, as for any draft (cache: 'no-store').
  • Protect it. It shows everything that isn't published yet to anyone with the URL.
  • It doesn't replace the canvas: it shows saved drafts, not what an editor is typing, and has no prompt bar. Set the environment's preview URL to it, include the bridge, and editors get both.

Next.js draft mode

Draft mode is Next's switch for one browser to skip the caches: while its cookie is set, fetch and 'use cache' skip the cache, static pages render on request, and responses are sent with Cache-Control: private, no-cache, no-store. Editing on the site turns it on at sign-in and off at sign-out:

app/cms/[...path]/route.ts
import { createSiteEditing } from '@obelism/content/site'
import { draftMode } from 'next/headers'

const handler = createSiteEditing({
	spaceId: process.env.CONTENT_SPACE_ID!,
	environment: 'production',
	onSignIn: async () => (await draftMode()).enable(),
	onSignOut: async () => (await draftMode()).disable(),
})

export { handler as GET, handler as POST }

Read the site token only while draft mode is on, so published pages never look at cookies:

import { readSiteToken } from '@obelism/content/site'
import { cookies, draftMode } from 'next/headers'

export const getContent = async () => {
	if ((await draftMode()).isEnabled) {
		const token = readSiteToken(await cookies())
		if (token) return drafts(token)
	}
	return delivery
}

Next can't read cookies inside 'use cache', so call this outside any cached function and pass the client in.

Draft mode and the site token end together on sign-out, but not otherwise: the token's cookie expires with the token after 8 hours, while Next's draft mode cookie lasts until the browser closes. The page then shows published content, rendered per request, until the editor signs in again or leaves through /cms/logout. A token revoked before then (the person left the organization) gets a 401, and the fallback above shows published content too.

An exit link of your own goes to /cms/logout as a plain <a> or a form, or with prefetch={false} on next/link: Next prefetches links, and a prefetch of /cms/logout would end the session before anyone clicks.

The canvas doesn't use draft mode: its token arrives on every frame's URL, so a page that reads it from searchParams works without cookies inside the dashboard's frame.

React Router: no draft mode

React Router has no switch to flip. The site token's cookie is the switch: read it from the request on every request (the contentFor function above), fetch drafts with cache: 'no-store', and answer those responses with Cache-Control: private, no-store and X-Robots-Tag: noindex yourself. Keep them out of your own loader caches and out of Oxygen's full-page cache: the token arrives on the same URL as the public page.

The cookie expires after 8 hours and on sign-out, and a refused token gets a 401, so the fallback above shows the published page; there is nothing else to turn off. The exit link goes to /cms/logout as a plain <a>, or with prefetch="none": a prefetch would end the session before anyone clicks.

Shopify Oxygen has the host's side: a public environment for the canvas, and the headers the editor needs.

Troubleshooting

  • The canvas shows the published page. See when a frame shows published content.
  • The canvas shows a Shopify login. The environment is private, and the frame is loaded by the editor's browser, which isn't signed in to your store. Make that environment public, or point the preview URL at one that is (Shopify Oxygen).
  • Signed in on the site, but the page is published content. Your page doesn't read the site token: check that the code above runs on the route (in Next.js, that draft mode is on and the page reads cookies() only then), and that your CDN didn't answer from its cache (rule 4).
  • 403 reading drafts. A draft token from a browser on another origin than the preview URL: read on the server, or set the preview URL to the origin you are on. With a connected deployment and code: drafts_not_allowed, its connection's rule reads published content only: allow drafts in that rule, or leave drafts off.
  • 401 with token_place_mismatch. The token is for another space or environment than your site names. Check CONTENT_SPACE_ID and CONTENT_ENVIRONMENT against the environment whose preview URL points here.
  • Drafts show to visitors. A preview token in a deployment the public reaches, or a draft response cached under the public URL. Swap the token for a delivery token and purge that cache.

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?