# Quickstart

<ExperimentalFeatureAside feature="Canvas Headless" />

**Goal:** have the [Drupal Canvas](/start-here/glossary/#drupal-canvas) editor render its preview through your own frontend app, with your app's [components](/start-here/glossary/#component) placeable on a page by an editor.

## What you'll have when you're done

- A frontend app scaffolded from the [Canvas Headless](/start-here/glossary/#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](/start-here/glossary/#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

- 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 <a href="https://www.acquia.com/products/acquia-cloud-platform/trial" target="_blank" rel="noopener">Cloud Platform free trial</a> 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, <a href="https://www.acquia.com/request-a-demo/acquia-source" target="_blank" rel="noopener">request a Source demo</a>.)
- The `Administer Canvas Headless frontends` and `Access Canvas Headless preview` permissions on your account. Both are restricted permissions granted per role.
- [Node.js](https://nodejs.org/) 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.

## Check Canvas Headless is on

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.

## Steps

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

<Steps>

1. ### Scaffold the frontend app

   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](/source-cms/get-content/wire-an-existing-app/) replaces this step and step 2; pick back up at step 3.

   <Tabs syncKey="method">
   <TabItem label="Yourself">

   <Tabs syncKey="framework">
     <TabItem label="Next.js">
       ```bash
       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/`.
     </TabItem>
     <TabItem label="Astro">
       ```bash
       npx @drupal-canvas/create@latest my-canvas-frontend \
         --template astro \
         --site-url https://your-site.example.com \
         --agents none
       ```

       The Astro template wires the adapter in `astro.config.mjs` as the `canvas()` integration, which injects the Canvas routes for you. Draft preview is per-request, so the project runs with an SSR adapter (`@astrojs/node`).
     </TabItem>
     <TabItem label="Nuxt">
       ```bash
       npx @drupal-canvas/create@latest my-canvas-frontend \
         --template nuxt \
         --site-url https://your-site.example.com \
         --agents none
       ```

       The Nuxt template wires the adapter by listing `@drupal-canvas/headless-nuxt` in `modules`, and the module mounts the Canvas routes as Nitro server handlers.
     </TabItem>
     <TabItem label="TanStack Start">
       ```bash
       npx @drupal-canvas/create@latest my-canvas-frontend \
         --template tanstack-start \
         --site-url https://your-site.example.com \
         --agents none
       ```

       The TanStack Start template wires the adapter in `vite.config.ts` with the `canvas()` plugin, and mounts the Canvas routes itself as five route files under `src/routes/api/`, the fifth serving the editor's isolated component preview.
     </TabItem>
   </Tabs>

   ```text
   ┌   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.

   </TabItem>

   <TabItem label="With an AI agent">

   Have your coding agent drive the scaffold. Drop `--agents none` so the scaffolder also generates rule files for your agent (Cursor, Copilot, and similar), and hand it the goal:

   ```text
   Scaffold a Drupal Canvas headless frontend with
   npx @drupal-canvas/create@latest my-canvas-frontend --template <nextjs|astro|nuxt|tanstack-start> --site-url https://<my site>
   (keep the agent rule files it generates), start the dev server, and confirm
   GET /api/canvas/components answers 401 with WWW-Authenticate: Bearer.
   ```

   This page's Markdown export (the `View as Markdown` control at the top) gives your agent the complete instructions and captured output. With that run done, the app is up and answering; continue at [register the app with Canvas](#register-the-app-with-canvas).

   </TabItem>
   </Tabs>

   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. ### Start the app and check the Canvas routes

   ```bash
   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:

   ```bash
   curl -i http://localhost:3000/api/canvas/components
   ```

   ```text
   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. ### 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:

   - `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 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>.`

   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. ### Place one of your components

   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. ### Add a component of your own

   <Tabs syncKey="method">
   <TabItem label="Yourself">

   From the project root:

   ```bash
   npx canvas scaffold --name my-hero
   ```

   ```text
   ┌   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:

   ```yaml
   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.

   </TabItem>

   <TabItem label="With an AI agent">

   Ask your agent for the component; the [Components](/source-cms/get-content/components/) page's Markdown export gives it the identity and status rules:

   ```text
   Scaffold a Canvas component with npx canvas scaffold --name my-hero, then in its
   component.yml set machineName: my-hero and status: true.
   ```

   Reload the Canvas editor afterwards; components synchronize when it loads.

   </TabItem>
   </Tabs>

</Steps>

## 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](/start-here/glossary/#machine-name) in your codebase. That is the part plain [JSON:API](/start-here/glossary/#jsonapi) 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](/source-cms/content-api/fetch-content/) 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.

## When something goes wrong

**`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.

## Next steps

- [Components](/source-cms/get-content/components/): where this app's components live, what gives one its identity, and how they stay in sync with the site.
- [First fetch](/source-cms/content-api/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](/source-cms/canvas-components/quickstart/): props, slots, and the component format your app's component directory already uses.
- [Canvas CLI reference](/source-cms/reference/canvas-cli/): `canvas pull` brings components that already exist on your site into the app's codebase.
- [Fetch content directly](/source-cms/content-api/fetch-content/): where direct fetches belong, caching, and the JSON:API queries that sit alongside `fetchPage()`.
