Skip to content

Goal: have the Drupal Canvas editor render its preview through your own frontend app, with your app’s components placeable on a page by an editor.

  • A frontend app scaffolded from the Canvas Headless template (or an app you already have, wired with the same SDK), running locally and serving the two routes Canvas requires of it.
  • That app registered on your site’s Headless frontends list, showing status Ready.
  • The Canvas editor rendering its preview from your app instead of from Drupal, unpublished content included.
  • Your app’s own components in the Canvas component library, and one of them placed on a page.
  • A Source CMS site you can administer: the Edit button (top right on any page of the site) opens the Canvas visual editor. (No site? A self-serve Cloud Platform free trial gets you building on Acquia the same day, though its Drupal 11 application is the Cloud Platform path, not a Source CMS site; for one of those, request a Source demo.)
  • The Administer Canvas Headless frontends and Access Canvas Headless preview permissions on your account. Both are restricted permissions granted per role.
  • Node.js 22 or later; npm ships with it.
  • A Chromium-based browser while you develop over plain HTTP. The editor embeds your app cross-origin and the preview session rides on a partitioned cookie, so Firefox needs HTTPS and Safari needs a version with CHIPS support.

Canvas Headless is on when Headless frontends sits in the icon rail down the left edge of the Canvas editor; seeing it needs the Administer Canvas Headless frontends permission from the prerequisites. On a Source CMS site, where Acquia manages the codebase, that is the whole check.

Pick your framework. The tab you choose stays selected across pages.

  1. The scaffolder writes CANVAS_SITE_URL into the project’s .env from --site-url, so point it at your site. To connect an app you already have, Wire an app you already have replaces this step and step 2; pick back up at step 3.

    Terminal window
    npx @drupal-canvas/create@latest my-canvas-frontend \
    --template nextjs \
    --site-url https://your-site.example.com \
    --agents none

    The Next.js template wires the adapter in next.config.ts with withCanvas(), and mounts the Canvas routes itself as four files under app/api/.

    ┌ Drupal Canvas create
    ◇ Fetched template
    ● Created .env from .env.example.
    ● No additional agents selected. Skipping compatibility setup.
    ◇ Installed dependencies with npm
    ◇ Initialized Git repository on main with initial commit
    ◇ Get started ─────────────────────────────╮
    │ │
    │ Created project in ./my-canvas-frontend │
    │ │
    │ Next steps: │
    │ cd my-canvas-frontend │
    │ npm run dev │
    │ │
    ├───────────────────────────────────────────╯
    └ Canvas project created successfully.

    --agents none skips generating per-agent rule files for Cursor, Copilot, and similar.

    Whatever the framework, the template gives you the same four things: a catch-all route that resolves any Drupal path with fetchPage() and renders the result with <CanvasComponentTree>, the draft session routes, the component metadata endpoint at /api/canvas/components, and a directory of ready-made components. CANVAS_SITE_URL is the entire configuration. There is no client secret, because the app never holds a standing credential.

  2. Terminal window
    cd my-canvas-frontend
    npm run dev

    The dev server prints its local URL: http://localhost:3000 for the Next.js, Nuxt, and TanStack Start templates, http://localhost:4321 for Astro. Confirm the app is serving the Canvas contract, against the URL yours printed:

    Terminal window
    curl -i http://localhost:3000/api/canvas/components
    HTTP/1.1 401 Unauthorized
    content-security-policy: frame-ancestors 'self'
    cache-control: no-store
    content-type: application/json
    www-authenticate: Bearer
    {"error":"missing_assertion","message":"Provide a Drupal preview assertion as a Bearer token. Assertions are single-use; mint a fresh one per request."}

    A 401 here is the success case: the route exists and is refusing a caller that brought no credential. This is the exact response Canvas looks for when it decides whether an app is connected. The frame-ancestors header is the adapter’s doing; it keeps the app un-embeddable until a live preview session names the editor’s origin. The TanStack Start template’s response carries every header shown except content-security-policy: its adapter currently sets that header on rendered pages, not on this route.

  3. Open Canvas and pick Headless frontends from the icon rail down the left edge. Click Add frontend, put the URL your dev server printed into the dialog’s one Frontend URL field, and confirm. The URL takes no trailing slash, no query, and no fragment.

    Your browser, not the Drupal server, probes each registered app and labels the row:

    • Ready: the app answered at /api/canvas/components the way the adapter does.
    • Setup needed: something answered at that URL, but not a Canvas adapter.
    • Unreachable: nothing answered.

    Failed rows are re-probed every few seconds, so a row that starts wrong turns green on its own once you fix the app. Canvas previews through the first app in the list; drag rows to reorder.

  4. Open any page of your site and click Edit. The editor no longer draws the preview itself: it embeds your app in a frame, and what fills the canvas is your frontend rendering that page, drafts included. The toolbar names the active app’s host and lets you switch between registered apps, and a status line above the frame reads Draft session active — renews automatically around <time>.

    This is site-wide, not per page. Once a frontend is registered, every Canvas editing session on the site previews through the active app.

    The same load synchronizes components. Canvas reads your app’s component metadata endpoint and registers every component it finds, so the component library now lists the components in your codebase. There is nothing to push.

  5. Open the component library and drag one of your app’s components onto the page: the template ships a set of them, including Hero, Card, and Accordion. Canvas records the placement and your app renders it in the frame, with the props declared in that component’s component.yml now editable in the right-hand panel. Publish the page and the same composition renders on the app’s own URL, this time as ordinary application markup: the selection markers only exist while a draft session is live.

  6. From the project root:

    Terminal window
    npx canvas scaffold --name my-hero
    ┌ Drupal Canvas scaffold
    ◇ Created component
    │ Created: my-hero
    │ Directory: components/my-hero
    │ Component metadata: components/my-hero/component.yml
    │ Source file: components/my-hero/index.jsx
    │ CSS file: components/my-hero/index.css
    └ Scaffold completed

    The directory comes from componentDir in canvas.config.json: components in the Next.js template, src/components in Astro and TanStack Start, app/components in Nuxt.

    Open the new component.yml and change two lines. machineName arrives as the placeholder hello-world, the same for every component the scaffolder writes, so the second one you scaffold would collide with the first. It is the component’s identity in Canvas and the key your app’s generated registry renders by; it does not have to match the directory name, but a name that does is easier to live with. status arrives as false, which registers the component without offering it for placement:

    name: My Hero
    machineName: my-hero
    status: true

    Reload the Canvas editor. My Hero is now in the component library, ready to drag onto a page. Drupal will not let anyone rename it or flip its status there: an external component’s identity belongs to the app that implements it. Change machineName later and Canvas treats it as a different component, registering the new name and deactivating the old entry.

Two halves met. On the app side, CANVAS_SITE_URL is the whole configuration, because the credential is minted per preview rather than stored. When you opened the editor, Drupal signed a single-use, short-lived assertion and pointed the embedded frame at your app’s /api/draft, which exchanged it at your site’s /oauth/token for an access token bound to your own user account. Unpublished content appears in the preview because you are allowed to see it, not because the app holds a privileged key, and the token’s reach is capped to a read-only preview ceiling.

On the rendering side, the catch-all route hands the requested path to fetchPage(), which asks Drupal to resolve it the way it would for a visitor and hand back the routed entity as a tree of elements rather than as finished HTML. <CanvasComponentTree> walks that tree and renders each element with the component of the same machine name in your codebase. That is the part plain JSON:API cannot do: JSON:API answers “give me entity X”, not “what does this URL resolve to, and which components compose it”. Listings and detail queries still go over JSON:API, through the same client the direct-fetch guide covers.

Canvas Headless first shipped in Drupal Canvas 1.9.0, released 29 July 2026. Its SDK packages are all pre-1.0, so treat their APIs as movable.

Error: CANVAS_SITE_URL must be set. See .env.example., and the dev server never starts

the app has no .env, or the file is not in the project root next to package.json. The scaffolder writes it from --site-url; if you scaffolded without that flag, copy .env.example to .env and fill in your site’s base URL with no trailing slash and no path.

The frontend row shows Setup needed

a server answered at that URL, but not with the adapter’s 401. Run the curl check from step 2 against the exact URL you registered. The usual causes are a wrong port and an app whose adapter never reached the build: withCanvas() in next.config.ts, canvas() in astro.config.mjs’s integrations, @drupal-canvas/headless-nuxt in nuxt.config.ts’s modules, or canvas() in vite.config.ts’s plugins for TanStack Start.

The frontend row shows Unreachable

nothing answered. Canvas probes each app from your browser rather than from the Drupal server, so a localhost dev server is fine, but the URL has to be one your browser can open, and the dev server has to still be running.

The preview never starts, or starts and stays blank, in Firefox or Safari

the preview session depends on a cookie set cross-origin, in a partition. Chromium-based browsers work over HTTPS, and also against a plain-HTTP localhost dev server. Firefox needs HTTPS, plus a per-site exception for the Drupal site if you block all third-party cookies. Safari follows CHIPS availability, which it lacks in versions 18.5 through 26.1.

Headless frontends is missing from Canvas

your account does not have the Administer Canvas Headless frontends permission. Access Canvas Headless preview is the separate permission editors need to preview through an app, and a role can hold either one without the other.

One entity fails to open in preview while others work

previewing needs view access to the entity, not only edit access. An entity with no canonical URL, or one whose path your app does not serve, cannot be previewed either.

A component you added is not in the component library

check its component.yml. status has to be true; a component left at the scaffolder’s status: false does synchronize, and Drupal records it, but it is not offered for placement. Check machineName too: left at the scaffolder’s hello-world, a second component collides with the first. Reload the editor afterwards, since components synchronize when the editor loads.

  • Components: where this app’s components live, what gives one its identity, and how they stay in sync with the site.
  • First fetch: the advanced path. Add direct JSON:API reads to this app with the Drupal API Client; the steps apply to this app as written.
  • Canvas components quickstart: props, slots, and the component format your app’s component directory already uses.
  • Canvas CLI reference: canvas pull brings components that already exist on your site into the app’s codebase.
  • Fetch content directly: where direct fetches belong, caching, and the JSON:API queries that sit alongside fetchPage().

Was this page helpful?