Headless - Setup
The goal of this page: have the Canvas editor render its preview through your own frontend app, with your app’s components placeable on a page by an editor.
What you’ll have when you’re done
Section titled “What you’ll have when you’re done”- A frontend app scaffolded from a 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.
Prerequisites
Section titled “Prerequisites”- A Drupal 11.3 or later site with Canvas installed that you can administer. The Edit button in the top right of any page opens the Canvas visual editor.
- The
canvas_headlesssubmodule enabled, so the editor can embed your frontend app and register its components. It pulls insimple_oauth(6.1.0 or later),consumers, andcustom_elements. Configure Simple OAuth’s RSA keypair at/admin/config/people/simple_oauth, since the same keypair signs preview assertions. - The Administer Canvas Headless frontends and Access Canvas Headless preview permissions on your account. Both are restricted permissions granted per role.
- Node.js 22.19 or later, or 24.5 or later. Node 23 is not supported. 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.
-
Scaffold the frontend app
Section titled “Scaffold the frontend app”@drupal-canvas/createwritesCANVAS_SITE_URLinto the project’s.envfrom--site-url, so point it at your site. To connect an app you already have, wire the framework adapter into it yourself and 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 noneThe Next.js template wires the adapter in
next.config.tswithwithCanvas(), and mounts the Canvas routes itself as four files underapp/api/.Terminal window npx @drupal-canvas/create@latest my-canvas-frontend \--template astro \--site-url https://your-site.example.com \--agents noneThe Astro template wires the adapter with
canvas()in theintegrationsarray ofastro.config.mjs, which injects the Canvas routes.Terminal window npx @drupal-canvas/create@latest my-canvas-frontend \--template nuxt \--site-url https://your-site.example.com \--agents noneThe Nuxt template wires the adapter by listing
@drupal-canvas/headless-nuxtin themodulesarray ofnuxt.config.ts.Terminal window npx @drupal-canvas/create@latest my-canvas-frontend \--template tanstack-start \--site-url https://your-site.example.com \--agents noneThe TanStack Start template wires the adapter with
canvas()in thepluginsarray ofvite.config.ts.Terminal window npx @drupal-canvas/create@latest my-canvas-frontend \--template angular \--site-url https://your-site.example.com \--agents none--agents noneskips 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_URLis the entire configuration. There is no client secret, because the app never holds a standing credential. -
Start the app and check the Canvas routes
Section titled “Start the app and check the Canvas routes”Terminal window cd my-canvas-frontendnpm run devThe dev server prints its local URL:
http://localhost:3000for the Next.js, Nuxt, and TanStack Start templates,http://localhost:4321for Astro. Confirm the app is serving the Canvas contract, against the URL yours printed:Terminal window curl -i http://localhost:3000/api/canvas/componentsHTTP/1.1 401 Unauthorizedcontent-security-policy: frame-ancestors 'self'cache-control: no-storecontent-type: application/jsonwww-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-ancestorsheader is the adapter’s doing; it keeps the app un-embeddable until a live preview session names the editor’s origin. -
Register the app with Canvas
Section titled “Register the app with Canvas”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:
Status Meaning Ready The app answered at /api/canvas/componentsthe 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.
-
Open a page in the Canvas editor
Section titled “Open a page in the Canvas editor”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>.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.
-
Place one of your components
Section titled “Place one of your components”Open the component library and drag one of your app’s components onto the page: the templates ship 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.ymlnow 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.
-
Add a component of your own
Section titled “Add a component of your own”From the project root:
Terminal window npx canvas scaffold --name my-heroThe directory comes from
componentDirincanvas.config.json:componentsin the Next.js template,src/componentsin Astro and TanStack Start,app/componentsin Nuxt. See Local codebase for the full set of options.Open the new
component.ymland change two lines.machineNamearrives as the placeholderhello-world, the same for every componentcanvas scaffoldwrites, 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.statusarrives asfalse, which registers the component without offering it for placement:name: My HeromachineName: my-herostatus: trueReload 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
machineNamelater and Canvas treats it as a different component, registering the new name and deactivating the old entry.
What just happened
Section titled “What just happened”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.
Next steps
Section titled “Next steps”- Writing portable components - fetch data with the same component in Drupal, Workbench, and headless frontends.
- Component metadata - props, slots, and the component format your app’s component directory already uses.
- Local codebase -
canvas pullbrings components that already exist on your site into the app’s codebase. - Multilingual sites - configure language negotiation, translation links, and frontend routing.