Zum Inhalt springen
Externa

Live Preview in practice with a Next starter

Open Externa drafts in your Next.js app: Live Preview URL, GET /api/v1/preview, same components as production.

Luca Giardi21. Oktober 2026Aktualisiert am 23.10.20269 Min. Lesezeit

Dieser Artikel ist nur auf Englisch verfügbar.

Getestet mit externa-core 1.2.2, Next.js starter 1.0.4.

An Externa item form next to the Live Preview URL template and the signed-token request a Next.js preview page makes.

Editors want to see a post the way readers will see it before they hit Publish. Externa's Live Preview does exactly that: from the item form it opens your own frontend with a short-lived, signed token, and your frontend swaps that token for the item's content. No API key in the URL, no second "preview CMS".

In this tutorial we'll add a /preview/[collection]/[id] route to the blog from Build a blog with Next.js and Externa, render drafts with the same components as the live site, and handle expired links gracefully.

Everything here matches Externa core 1.2.2 and the current externa-next-starter (v1.0.4, Next.js 15 App Router).

Prerequisites: Externa on PHP 8.4.27+ or 8.5.12+ (composer ^8.4.27), the blog from the previous article (or any app based on externa-next-starter), and an admin who can edit the collection. To preview drafts specifically, you'll turn on content versioning in step 1.


What Live Preview is, and what it isn't

Live Preview opens your headless frontend for one item, in its draft or published version, from the admin item form. It's easy to mix up with Preview as role, which is a permissions tool (Live Preview):

AspectLive PreviewPreview as role
PurposeSee the item in your real frontendCheck which fields a role can see (JSON)
SecretSigned {{token}} in the URLAdmin session
AudienceEditors + frontend developersAdmins checking permissions

What it doesn't do: it doesn't embed your site in an iframe inside the admin, and it doesn't stream keystrokes to the frontend. You save, then open the preview in a new tab.

1. Turn on content versioning (for drafts)

Open the articles collection's edit drawer and enable Content versioning. Editors then get Draft and Published workspaces on the item form (Content versioning):

  • In Draft, saving writes to the item's draft_data only. Published values don't change.
  • Publish copies draft_data into the published values and clears the draft. Discard draft throws it away.
  • The Public CMS API (/api/v1/collections/...) keeps returning published values. Drafts are only reachable through Live Preview.

Live Preview also works without versioning; it then always shows the published version.

2. Configure the Live Preview URL

In the same collection drawer, fill in Live Preview URL. If you leave it empty, Externa uses Settings → Project → Live Preview URL (default). The template must start with http:// or https:// and can contain these tokens (Live Preview: Configure):

TokenValue
{{id}}Item id
{{collection}} / {{slug}}The collection slug (both tokens give the same value; neither is the item's own slug field)
{{locale}}Optional content locale; the query parameter is dropped when it's empty
{{version}}draft or published
{{token}}Short-lived HMAC preview token (about 15 minutes)

For local development with the starter:

http://localhost:3002/preview/{{collection}}/{{id}}?version={{version}}&token={{token}}

3. What editors see

On an existing item, the toolbar shows a Live Preview button (an external-link icon) once a template is configured. Clicking it calls GET /collections/{collection}/items/{item}/live-preview-url?version=draft|published (it requires can-show-collections), gets back the expanded URL, and opens it in a new tab (Live Preview: Admin UX).

When versioning is on, the version follows the workspace the editor is in: from Draft you get version=draft, from Published you get version=published. Each click mints a fresh token.

4. The frontend contract

Your preview route does three things (Live Preview: Frontend contract):

  1. Read token from the query string.
  2. Call GET {EXTERNA_API_URL}/api/v1/preview?token=….
  3. Render data.
curl -sS "http://externa-core.test/api/v1/preview?token=TOKEN" \
  -H 'Accept: application/json'
{
  "data": {
    "id": 1,
    "collection_id": 3,
    "data": { "title": "…" }
  },
  "meta": {
    "preview": true,
    "version": "draft",
    "locale": "en",
    "expires_at": "2026-10-21T12:00:00Z"
  }
}

The token is the credential, and it decides which item and version you get. The version in your URL is only there for your own routing or display; trust meta.version.

Errors: a missing token gives 422, an invalid or expired token gives 403.

The shape of data.data

This is where preview differs from the list endpoint you used for the blog, so it's worth a closer look:

  • Draft (meta.version is draft and the item has a pending draft): data.data is the raw draft_data bag. Translatable fields are locale maps ({ "en": "…", "it": "…" }), and file and relation fields hold ids.
  • Published (or a draft request on an item with no pending draft): the same item shape as the Public CMS API, flattened to one locale. The preview request has no include, so files and relations are ids here too.

Our blog helpers already cope with both: text() accepts a string or a locale map, and PostBody reads block fields either way. Only the cover needs one extra call, below.

5. Build app/preview/[collection]/[id]/page.tsx

The route is a Server Component, reuses the starter's externaFetch() (which uses cache: 'no-store', exactly what a preview needs), and renders the same PostView component as /blog/[slug]:

// app/preview/[collection]/[id]/page.tsx
import type { Metadata } from 'next'
import { PostView } from '@/components/blog/post-view'
import { text } from '@/lib/blog'
import { ExternaHttpError, externaFetch } from '@/lib/externa/client'

// Preview pages should never end up in a search index (a Next.js setting, not an Externa feature).
export const metadata: Metadata = { robots: { index: false, follow: false } }

type PreviewResponse = {
  data: { id: number; collection_id: number; data: Record<string, unknown> }
  meta: {
    preview: true
    version: 'draft' | 'published'
    locale: string
    expires_at: string
  }
}

type Props = {
  params: Promise<{ collection: string; id: string }>
  searchParams: Promise<{ token?: string | string[] }>
}

/** Preview payloads are not expanded, so file fields hold an id. Resolve it via the Files API (needs Files → Read). */
async function fileUrl(value: unknown): Promise<string | null> {
  if (typeof value !== 'number') return null
  try {
    const res = await externaFetch<{ data: { url: string } }>(
      `/api/v1/files/${value}`,
    )
    return res.data.url
  } catch {
    return null
  }
}

export default async function PreviewPage({ params, searchParams }: Props) {
  const { id } = await params
  const { token } = await searchParams
  const value = Array.isArray(token) ? token[0] : token

  if (!value) {
    return (
      <PreviewProblem message="This link has no preview token. Open the item in Externa and click Live Preview." />
    )
  }

  let preview: PreviewResponse
  try {
    // The token is the credential: no API key in the URL. externaFetch adds the server-side
    // Bearer key only if EXTERNA_API_KEY is set (needed when Allowed origins is configured).
    preview = await externaFetch<PreviewResponse>(
      `/api/v1/preview?${new URLSearchParams({ token: value })}`,
    )
  } catch (error) {
    if (
      error instanceof ExternaHttpError &&
      [403, 404, 422].includes(error.status)
    ) {
      return (
        <PreviewProblem message="This preview link is invalid or has expired. Go back to Externa and click Live Preview again." />
      )
    }
    throw error
  }

  const { data: item, meta } = preview
  if (String(item.id) !== id) {
    return (
      <PreviewProblem message="This preview token belongs to a different item." />
    )
  }

  const coverUrl = await fileUrl(item.data.cover)

  return (
    <>
      <p className="preview-banner">
        Preview · {meta.version} · {meta.locale} · expires{' '}
        {meta.expires_at.replace('T', ' ').replace('Z', ' UTC')}
      </p>
      <PostView
        title={text(item.data.title, meta.locale)}
        excerpt={text(item.data.excerpt, meta.locale)}
        cover={coverUrl ? { url: coverUrl, alt: '' } : null}
        body={item.data.body}
        locale={meta.locale}
      />
    </>
  )
}

function PreviewProblem({ message }: { message: string }) {
  return (
    <div className="error">
      <p>{message}</p>
    </div>
  )
}

And a banner style in app/globals.css:

.preview-banner {
  padding: 0.5rem 0.75rem;
  border-radius: 6px;
  background: #fff4d6;
  color: #5c4400;
  font-size: 0.875rem;
}

What's going on:

  • searchParams and params are promises in Next.js 15, hence the awaits.
  • No API key in the URL. If you've set Allowed origins in Externa, though, the preview endpoint is behind the same origin gate as the rest of /api/v1, so a server-side call needs EXTERNA_API_KEY. externaFetch() sends it as a header when it's set.
  • Expired links get a friendly message instead of an error page. Editors just click Live Preview again.
  • The cover is resolved through GET /api/v1/files/{id}, because preview payloads aren't expanded. That call uses the normal grants, so the public role (or your key's role) needs Files → Read, the same as for the blog.
  • robots: noindex keeps preview pages out of search engines. That's a Next.js setting, not something Externa does for you.

Open an article in Externa, switch to Draft, change the title, save, and click Live Preview. The new tab shows your draft title under a yellow "Preview · draft" banner, while http://localhost:3002/blog still shows the published one.

6. Draft vs published, end to end

  1. Edit in Draft and save. Live Preview shows the draft; /blog/[slug] still shows the published values, because the Public CMS API only returns published content.
  2. Click Publish. Externa copies draft_data into the published values and clears the draft. If you use outbound webhooks, this emits item.published.
  3. Reload /blog/[slug]: the change is live. A Live Preview from the Published workspace now shows the same content.

If you schedule publishing instead (Publish at on a versioned item), the scheduler promotes the draft when it's due, so keep schedule:run (or the Compose scheduler service) running (Content versioning: Scheduled publish).

Security notes

  • Never put API keys in the preview URL. Use {{token}}; that's what it's for.
  • Treat preview links like temporary share links. Anyone with a live token can read that item's preview payload until it expires, and the draft payload contains every field of the item.
  • Tokens are HMAC-signed with APP_KEY. Rotating APP_KEY invalidates every outstanding preview link.

Optional: locales

The template supports {{locale}}, and the token can carry a locale, which the API then uses for the published payload (meta.locale tells you which one it used). As of 1.2.2, the toolbar button still doesn't pass a locale (the item form mounts it without one), so with the admin UI the token usually has none, and meta.locale is the resolved default. For drafts it matters less: the draft bag contains every locale, and text(value, locale) picks one. If your site serves several locales, pass the locale your route needs to text() instead of meta.locale. See Multilingual landing page with Nuxt and Externa for how locales work in the API.

Recap

  • Live Preview URL on the collection (or the project default), with {{collection}}, {{id}}, {{version}} and {{token}}.
  • A /preview/[collection]/[id] Server Component that swaps the token for content via GET /api/v1/preview, with no caching and no API key in the URL.
  • The same PostView component as production, so the preview really looks like the live page.
  • Drafts stay private until Publish; the Public CMS API keeps serving published values.

Use AI while you follow along

Paste Add Live Preview to a Next.js frontend into Cursor or Claude. It points the assistant at https://docs.qiick.io/externa/llms.txt and the Live Preview docs first, so it implements the documented token contract instead of inventing one.

Docs used in this article

Externa auf dem eigenen Server ausprobieren

Open Source unter MIT, selbst gehostet, auf Laravel-Basis.

Externa8 Min. Lesezeit

Externa Core 1.4.0 — MCP, AI approvals, and accessibility

A rebuilt admin with transparent AI answers, an optional MCP server, human approval for AI write tools, theme presets, GCS and Azure file storage, WCAG 2.2 AA accessibility work, and new Public API batch writes and filters.