← all posts
2026.11.12 · 4 min

Consumer Node Preview 1.0.0-rc1; preview for Fully Decoupled Drupal sites

Press Preview on a Fully Decoupled Drupal site, and Consumer Node Preview 1.0.0-rc1 shows the unsaved page in the frontend your readers will see: the title you just changed and the text you just rewrote, rendered by the site that will publish them.

Two unsaved edits, to the title and the first paragraph, rendered by the Druxt site readers see.

Drupal's own Preview renders the node in Drupal's theme, not your frontend, so editors save a draft to check it. Next.js draft mode smooths that workaround, but still needs a saved revision. Consumer Node Preview works on what's still in the edit form, for the person writing it, before anything is saved.

Anything that reads JSON:API can be on the other end. Druxt Node Preview 0.1.0 is the Nuxt one, already running druxtjs.org's previews.

Pointing Drupal at your frontend

Consumer Node Preview builds on JSON:API Node Preview, which serves the unsaved node over JSON:API.

Install both
composer require drupal/jsonapi_node_preview:^1.0@beta drupal/consumer_node_preview:^1.0@RC
drush en consumer_node_preview

It stays a release candidate while JSON:API Node Preview is in beta.

Each app that reads your API is a consumer, and each gets its own preview targets, so a Next site and an Astro build can preview the same draft. A target is a label and a URL template. Add it on the consumer's form under Configuration > Web services > Consumers, or in settings.php when the URL changes per environment:

settings.php
$settings['consumer_node_preview'] = [
  'targets' => [
    'frontend' => [
      'label' => 'Frontend',
      'url' => 'https://www.example.com/druxt/node/preview?vm=[view_mode]&layout=[layout]#[jsonapi_node_preview]',
      'layouts' => ['default', 'preview'],
    ],
  ],
];

Press Preview, and the module fills in the template's tokens and frames the page on the Frontend tab, so the page has to allow that: no X-Frame-Options, and Drupal's origin in any frame-ancestors rule. Beside it, Drupal is core's own preview, for anything Drupal renders itself, and JSON:API is the raw document: the tab I open first when a field doesn't turn up.

Keeping the draft out of the logs

A preview URL includes the address of an unsaved draft. In a path or query string, it would end up in the frontend's access logs, any CDN in front of it, and the Referer of everything the page loads. So [jsonapi_node_preview] goes after the #: browsers never send the fragment to a server. Scripts on the page can still read it, so keep analytics, error tracking and session replay off the preview route.

Sharing a domain: the editor's session

If Drupal and your frontend share a domain, like cms.example.com and www.example.com, the preview page can read with the editor's session cookie, once Drupal's CORS config names the frontend and allows credentials:

services.yml, same site
parameters:
  cors.config:
    enabled: true
    allowedHeaders: ['*']
    allowedMethods: ['GET']
    allowedOrigins: ['https://www.example.com']
    supportsCredentials: true

On another domain: a preview token

Browsers won't send that cookie to a frontend on another domain, such as example.vercel.app, so the preview reads with a token instead. Install Simple OAuth, then on the consumer's form turn on the Authorization Code grant, and give its Authorization Code settings a scope for a role made for previews, rather than your editors' own. A scope under Default scopes alone gets the preview a 403.

The consumer's Authorization Code grant, with a scope for the editor role. The preview token needs both.

End the target with ;token=[preview_token], and in settings.php name its consumer with 'consumer' => 'my-frontend'. Each Preview then adds a token to the fragment. It acts as the editor, limited to that role, for 15 minutes by default. It travels without a cookie, so CORS can allow any origin: without credentials, another site reads only what an anonymous visitor could. In services.yml, set allowedOrigins to ['*'] and supportsCredentials to false.

View modes and layouts

View modes, for Nuxt developers. Drupal's displays of the same content, such as Full or Teaser, each with its own fields. The editor's pick arrives as vm, and Druxt renders the node in that mode.

Layouts, for Drupal developers. Nuxt's page wrappers, the components in layouts/ that put the site around a page. The editor's pick arrives as layout, so one node can be checked inside the full site or on a bare page.

Druxt renders it like any other page

On a Druxt site, the preview page is a Nuxt module that can register its own target:

nuxt.config.js
// npm install druxt-site @druxt-contrib/node-preview
export default {
  modules: ['@druxt-contrib/node-preview', 'druxt-site'],
  druxt: {
    baseUrl: 'https://cms.example.com',
    proxy: { api: true },
    nodePreview: {
      layouts: ['default', 'preview'],
      register: {
        url: 'https://example.vercel.app',
        consumer: 'my-frontend', // The consumer's client ID.
        token: true,
      },
    },
  },
}

With the secret of a consumer set up for Simple OAuth in DRUXT_NODE_PREVIEW_CLIENT_SECRET, Nuxt adds its target to Drupal when it starts and every six hours, and removes it on a clean shutdown. A target that stops registering goes inactive after a day, so a crashed server cleans up after itself. With token on and Druxt's API proxy, the read never leaves the frontend's origin, and Drupal's CORS stays as it is.

The preview goes in the Druxt store before DruxtEntity resolves, so it renders through the same components as the live page, and the token comes off the address on load. It only fetches from its own origin, its Drupal site, or origins you list, so a crafted link can't point the preview, or the editor's token, at somebody else's server.

A preview page for Next, Astro or Nuxt 3

Nothing in the URL template is Druxt-specific. Druxt Node Preview is for Nuxt 2 and Node 16, and stays 0.x until Druxt reaches 1.0.0. In Nuxt 3, Next or Astro, this is the core of a preview page:

A preview page for any other frontend
// Target URL: https://www.example.com/preview#[jsonapi_node_preview];token=[preview_token]
// Runs in the browser, in a module script: the fragment never reaches a server.
// The origins this page may fetch a preview from.
const allowed = ['https://cms.example.com']

// Some routers turn https:// into https:/ inside a hash.
const hash = decodeURIComponent(location.hash.slice(1))
const [address, token] = hash
  .replace(/^(https?):\/(?!\/)/, '$1://')
  .split(';token=')
const endpoint = new URL(address)
// Take the token back out of the address bar.
history.replaceState(null, '', location.pathname + location.search)

if (!allowed.includes(endpoint.origin)) {
  throw new Error(`Refusing a preview from ${endpoint.origin}`)
}

const response = await fetch(endpoint, {
  // A token target reads without the cookie.
  credentials: token ? 'omit' : 'include',
  headers: {
    Accept: 'application/vnd.api+json',
    ...(token && { Authorization: `Bearer ${token}` }),
  },
})
if (!response.ok) throw new Error(`Preview failed: HTTP ${response.status}`)
const { data, included } = await response.json()
// Render data and included with the components a live page uses.
Worth calling out

Help paragraph preview land

Unsaved paragraphs come through with the patch on #3367724, which druxtjs.org runs as a Composer patch. If you preview paragraphs, test it on your site and leave a review. It needs a tested review to land.

Consumer Node Preview's code is on GitHub, and its releases and issue queue are on Drupal.org. If your editors work on a Fully Decoupled site, sponsorship is what moves the diff work up the list.

Druxt Node Preview is on GitHub. Its end-to-end suite builds a Drupal site with both modules and previews through a real Druxt frontend.

Diffs are next: the changes toggle in the screenshot is druxtjs.org's early look at them.

If your frontend sits on a different domain to Drupal, is a 15-minute token long enough for the way your editors preview?

Read next

All posts

Discussion

via GitHub Discussions