Skip to content

Live previews

Live previews show the page you are editing next to the form in the dashboard. The page updates as the editor types, before anything is saved or published.

The Alinea dashboard editing a product, with a live preview of the page beside the formThe Alinea dashboard editing a product, with a live preview of the page beside the form
Editing a product in the Oak & Loom demo, with the page previewed live beside the form.

Set up

Turn on previews in your CMS config. The dashboard loads your site from baseUrl, so set the URL for every environment you edit in:

cms.ts
import {createCMS} from 'alinea/next'

export const cms = createCMS({
  // schema and workspaces ...
  baseUrl: {
    development: 'http://localhost:3000',
    production: 'https://example.com'
  },
  handlerUrl: '/api/cms',
  preview: true
})

Then render <cms.previews /> in your root layout:

app/layout.tsx
import {cms} from '@/cms'
import type {PropsWithChildren} from 'react'

export default function RootLayout({children}: PropsWithChildren) {
  return (
    <html lang="en">
      <body>
        {children}
        <cms.previews widget />
      </body>
    </html>
  )
}

Open an entry in the dashboard and the preview panel shows its page. cms.previews renders nothing unless the page is being previewed, so it is safe to leave in your production layout.

How it works

  1. The dashboard asks your handler for a short-lived preview token and loads /api/cms?preview=<token>&returnTo=<entry url> in the preview panel.

  2. The handler checks the token, turns on Next.js draft mode and redirects to the entry's URL.

  3. On every edit the dashboard sends the unsaved entry to the page. cms.previews stores it in a cookie and refreshes the route, so your server components render again with the edited content.

While draft mode is on, queries use status: 'preferDraft', so saved drafts show instead of the published version. Pass status to a query yourself to override this.

The panel loads the entry's url: by default the locale, the parent paths and the entry's own path, like /en/blog/my-post. If your routes differ, set entryUrl on the Type so the preview (and Query.url) point to the right page.

The preview widget

<cms.previews widget /> adds a small floating toolbar to previewed pages. It shows whether the page is connected to the dashboard and has buttons to open the dashboard and to edit the current page. Leave out widget to get live previews without the toolbar.

The props of cms.previews:

  • widget: show the floating toolbar, off by default.

  • workspace, root: the workspace and root the edit button looks in when it finds the entry by the current URL. Set them when several workspaces or roots can contain the same URL, for example in a layout per site.

Choose which entries have a preview

preview can be set on the config, a workspace, a root or a type. The most specific setting wins: type, then root, then workspace, then config. Turn it off for entries that have no page of their own, such as settings:

schema/Settings.ts
import {Config, Field} from 'alinea'

export const Settings = Config.document('Settings', {
  preview: false,
  fields: {
    siteName: Field.text('Site name')
  }
})

Media files never show a preview.

Preview entries without a page

Content that is only used inside other pages, such as reusable snippets, has no URL of its own to preview. Give its type an entryUrl that points to a route you only use for previews:

schema/Snippet.ts
import {Config, Field} from 'alinea'

export const Snippet = Config.document('Snippet', {
  entryUrl: ({path}) => `/preview/snippets/${path}`,
  fields: {
    body: Field.richText('Body')
  }
})
app/preview/snippets/[slug]/page.tsx
import {cms} from '@/cms'
import {Snippet} from '@/schema/Snippet'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'

interface SnippetPreviewProps {
  params: Promise<{slug: string}>
}

export const metadata: Metadata = {robots: {index: false}}

export default async function SnippetPreview({params}: SnippetPreviewProps) {
  const {slug} = await params
  const snippet = await cms.first({
    type: Snippet,
    url: `/preview/snippets/${slug}`
  })
  if (!snippet) notFound()
  return <main>{/* render the snippet like it appears on your pages */}</main>
}

Render a preview component

Instead of true, preview accepts a React component. The dashboard renders it in the preview panel instead of loading your site, and passes the entry with its current, unsaved field values in entry.data. Values are in their stored form: links are not resolved.

schema/Announcement.tsx
import {Config, Field} from 'alinea'

export const Announcement = Config.document('Announcement', {
  preview({entry}) {
    const {message} = entry.data as {message?: string}
    return <div style={{padding: 16}}>{message || entry.title}</div>
  },
  fields: {
    message: Field.text('Message')
  }
})

The component is part of your schema, which your site imports too. Keep it small and free of server-only imports.

Good to know

  • The metadata field shows a search and social preview built from the <title>, description and Open Graph tags the previewed page renders. Render them with generateMetadata to fill it.

  • Previews need your handler: in production the dashboard is served from your own site, so the preview panel and your pages share the same origin.

  • Pages render dynamically in draft mode. Statically generated pages still serve their built version to visitors.