Documentation Menu

Previewing drafts

8 min read

A content manager can open the draft they are editing on the real site, drawn by the site's own code, and the SDK reads that draft with the short-lived preview token the dashboard hands the site.

Why the only honest preview is the page itself

The dashboard's Content preview draws a document from its component. That shows the words, but not the site: not its fonts, not its layout, not the page around the content. The only honest preview of a page is the page itself, rendered by the code that will render it once it is live. A site cannot read drafts with its delivery key, and that is by design. So the dashboard mints a token that lets one read see one draft for fifteen minutes, and sends the token to an address on the site that knows what to do with it.

If you are a content manager, press Preview → On website in the page bar while you edit. A new tab opens on the site and shows your saved draft as readers would see it after you publish. Changes you have not saved are not in it, so save first. If the item is greyed out, the environment has no website preview address yet, and an engineer needs to set one.

How a preview token travels from dashboard to page

  1. An owner sets Website preview URL on the environment under Settings → Environments → Edit. This is the address on your site that receives preview tokens, such as https://www.example.com/api/preview, and it must be an https:// address on a public host.
  2. A content manager presses Preview → On website. The CMS mints a token for that document and locale and opens <preview URL>?token=…&key=…&locale=… in a new tab.
  3. Your preview route keeps the token in an httpOnly cookie and sends the reader on to the page for key.
  4. The page reads that document with q.get(key, { locale, preview: token }). The answer is the current draft, with _meta.preview: true.

The token is not a credential. The read still needs the site's delivery key, and the token only decides which version that read answers with. A token is good for one document, one locale and fifteen minutes. Use it to ask for any other document and the CMS answers 401, "The preview token is not valid for this request." An expired or damaged token gets the same answer, and the CMS deliberately does not say which of the three went wrong. The SDK throws an UnauthorizedError.

list, tree and walk take the same preview option. For them the CMS checks the token only against the project and environment, because a listing has no single key. A preview listing includes drafts that have never been published, with publishedAt: null, so a draft page's links to new siblings work. In practice walk is the exception. It passes the token on to the get it makes for every document, and the CMS refuses each document that is not the token's own, so a preview walk over a folder of more than one document throws UnauthorizedError. Use list or tree for the folder, and get for the one draft.

Nothing stores a preview read. The SDK's own cache skips it, Next's data cache skips it, and the CMS answers it with Cache-Control: no-store and no ETag.

How to

These examples are for a Next.js App Router site, but the SDK calls are the same anywhere. Start with the route the dashboard opens. It checks the three parameters, keeps the token and the key it belongs to in two cookies that last as long as the token, and redirects to the page:

// app/api/preview/route.ts: where Preview › On website lands.
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'

export async function GET(request: Request): Promise<Response> {
  const params = new URL(request.url).searchParams
  const token = params.get('token')
  const key = params.get('key')
  const locale = params.get('locale') ?? 'en-US'
  if (!token || !key) return new Response('Missing token or key.', { status: 400 })

  const jar = await cookies()
  const options = { httpOnly: true, secure: true, sameSite: 'lax' as const, maxAge: 900, path: '/' }
  jar.set('quillet-preview', token, options)
  jar.set('quillet-preview-key', key, options)
  redirect(`/${locale}/${key}`)
}

Read every page through one loader. It passes the token only for the document the token was minted for, and it treats a refused token as "show what is published". A reader whose cookie has outlived the token then sees the live page rather than an error:

// lib/draft-or-live.ts: the draft when this request is previewing this key, else the live page.
import { UnauthorizedError, type Document } from '@quilletcms/sdk'
import { cookies } from 'next/headers'
import { q } from '@/lib/quillet'

export async function loadPage(key: string, locale: string): Promise<Document<string>> {
  const jar = await cookies()
  const token = jar.get('quillet-preview')?.value
  const previewing = token !== undefined && jar.get('quillet-preview-key')?.value === key

  if (previewing) {
    try {
      return await q.get<string>(key, { locale, preview: token })
    } catch (error) {
      if (!(error instanceof UnauthorizedError)) throw error
    }
  }
  return q.get<string>(key, { locale })
}

Draw the "this is a draft" banner from _meta.preview, not from the cookie. The field says the CMS accepted the token, while the cookie only says one was sent:

// app/[locale]/[...key]/banner.ts: the banner over a previewed page.
import type { Document } from '@quilletcms/sdk'

export function banner(doc: Document<string>): string {
  if (doc._meta.preview !== true) return ''
  const state = doc._meta.publishedAt === null ? 'these changes are not live yet' : 'no unpublished changes'
  return `<div role="status">Preview: ${state}. <a href="/api/preview/exit">Leave preview</a></div>`
}

_meta.publishedAt is null whenever the version you are shown has not gone live, and that includes the draft of a page that is live today. A preview of a page with no unpublished changes carries the published time.

To let the reader leave preview, delete the cookies:

// app/api/preview/exit/route.ts: back to the published site.
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'

export async function GET(): Promise<Response> {
  const jar = await cookies()
  jar.delete('quillet-preview')
  jar.delete('quillet-preview-key')
  redirect('/')
}

A menu on a previewed page may need to list a sibling written in the same sitting. For that, read the folder around the page with the token, so the drafts are included:

// lib/siblings.ts: the folder as the previewing reader should see it.
import type { ListingItem } from '@quilletcms/sdk'
import { q } from '@/lib/quillet'

export async function siblings(key: string, token?: string): Promise<ListingItem[]> {
  const folder = key.slice(0, key.lastIndexOf('/') + 1)
  const listing = await q.list(folder, token ? { preview: token } : {})
  return listing.items
}

To try the flow with a site on your own machine, the CMS has to accept a plain http://localhost address. It checks the preview address the same way it checks a webhook address, so start the CMS with WEBHOOK_ALLOW_HTTP=1 and WEBHOOK_ALLOW_PRIVATE_NETWORKS=1. Never set either one on a server.

Limits

  • A token covers one document and one locale. To preview a second page, press On website a second time.
  • A token lasts fifteen minutes, and there is no way to revoke a single token earlier. Rotating the CMS's SESSION_SECRET retires every token at once.
  • The draft you see is the current saved version. You cannot preview an older version, and preview together with at is refused with 422.
  • There is no environment-wide preview. A token cannot show a whole site in its draft state.
  • The draft is merged under the published component. A component change that is still in draft is not what the preview is checked against.
  • There is no iframe beside the editor. Preview opens a new tab.
  • The SDK carries the token and does nothing more with it. It does not mint, verify or store it; your route keeps it, and only for as long as the preview needs.
  • search takes no token, because search covers published content only.
  • walk with a token fails on the first document that is not the token's own, so it cannot render a folder in its draft state.

Last updated