# Wire an app you already have

<ExperimentalFeatureAside feature="Canvas Headless" />

**Goal:** join an app you already have to [Canvas Headless](/start-here/glossary/#canvas-headless), reaching the same state the [quickstart](/source-cms/get-content/quickstart/)'s scaffold gives you: the app serving the Canvas contract, ready to register.

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

- The Canvas Headless SDK and your framework's adapter installed and wired in your app.
- The app answering the adapter's `401` at the component metadata route, ready for the quickstart's [registration step](/source-cms/get-content/quickstart/#register-the-app-with-canvas) and everything after it.

## Prerequisites

- An existing Next.js, Astro, Nuxt, or TanStack Start app you can add dependencies to.
- Everything from the [quickstart](/source-cms/get-content/quickstart/)'s prerequisites: a Source CMS site you can administer, the two Canvas Headless permissions, Node.js 22 or later, and a Chromium-based browser while you develop over plain HTTP.

## Steps

The [quickstart](/source-cms/get-content/quickstart/)'s first two steps scaffold a new project. An existing app reaches the same place by installing the adapter and wiring it itself, and the quickstart's remaining steps (registration, preview, components) then run unchanged. Do not hand-write the Canvas contract: the adapter ships the draft session exchange, the component metadata endpoint, the component registry, and the frame headers. Canvas checks for the adapter's exact responses when it decides whether your app is connected.

<Steps>

1. ### Add the two shared files

   Two files are the same whatever the framework. `canvas.config.json` at the project root tells component discovery where your components live:
   
   ```json
   {
     "componentDir": "src/components",
     "aliasBaseDir": ".",
     "globalCssPath": "src/styles/global.css"
   }
   ```
   
   `componentDir` is the only key that has to match your project; the value above is the default. And `.env` holds the whole of the app's Canvas configuration:
   
   ```bash
   CANVAS_SITE_URL=https://your-site.example.com
   ```

2. ### Install and wire the adapter

   <Tabs syncKey="framework">
     <TabItem label="Next.js">
       ```bash
       npm install @drupal-canvas/headless @drupal-canvas/headless-react @drupal-canvas/headless-next
       ```
   
       Wrap the Next.js config. `withCanvas()` transpiles the SDK packages, generates the component registry and keeps it current in development, writes the component manifest at build time, and sends the `Content-Security-Policy: frame-ancestors` header that keeps the app un-embeddable until a live preview session names the editor's origin:
   
       ```ts
       // next.config.ts
       import { withCanvas } from '@drupal-canvas/headless-next/config';
   
       export default withCanvas();
       ```
   
       Import it from `/config` rather than the package root. `next.config.ts` is loaded outside any request scope, and the root entry reaches for request-scoped Next.js APIs.
   
       Next.js is one of the two frameworks where you mount the Canvas routes yourself (TanStack Start is the other). Four files, each one line of behavior. The paths of the first and the last are fixed by the integration contract, so put them exactly here:
   
       ```ts
       // app/api/draft/route.ts
       import { createDraftRouteHandlers } from '@drupal-canvas/headless-next';
   
       export const GET = createDraftRouteHandlers().draft.GET;
       ```
   
       ```ts
       // app/api/draft/renew/route.ts
       import { createDraftRouteHandlers } from '@drupal-canvas/headless-next';
   
       export const POST = createDraftRouteHandlers().draftRenew.POST;
       ```
   
       ```ts
       // app/api/disable-draft/route.ts
       import { createDraftRouteHandlers } from '@drupal-canvas/headless-next';
   
       export const POST = createDraftRouteHandlers().disableDraft.POST;
       ```
   
       ```ts
       // app/api/canvas/components/route.ts
       import { createComponentMetadataHandler } from '@drupal-canvas/headless-next';
   
       export const runtime = 'nodejs';
       export const dynamic = 'force-dynamic';
       export const { GET, OPTIONS } = createComponentMetadataHandler();
       ```
   
       Leaving draft mode is a `POST` on purpose: a `GET` reachable by a link would be eligible for prefetching, and a prefetch would end the editor's session silently.
   
       Last, the catch-all that renders any path your site routes:
   
       ```tsx
       // app/[...slug]/page.tsx
       import { fetchPage } from '@drupal-canvas/headless-next';
       import CanvasComponentTree from '@drupal-canvas/headless-next/CanvasComponentTree';
       import { notFound } from 'next/navigation';
   
       export const dynamic = 'force-dynamic';
   
       export default async function CanvasPage({
         params,
       }: {
         params: Promise<{ slug: string[] }>;
       }) {
         const { slug } = await params;
         const path = `/${slug.map(encodeURIComponent).join('/')}`;
         const page = await fetchPage(path);
         if (!page) notFound();
   
         return <CanvasComponentTree tree={page.content} />;
       }
       ```
   
       If the app already owns `/`, or already has its own catch-all, keep them: `fetchPage()` is a per-route call, not a takeover. Only the paths you hand to it are rendered by Canvas.
     </TabItem>
     <TabItem label="Astro">
       ```bash
       npm install @drupal-canvas/headless @drupal-canvas/headless-astro
       ```
   
       Add the integration. Draft preview is per-request, so the project also needs an SSR adapter and `output: 'server'`; `npx astro add node` installs `@astrojs/node` if the app is still static:
   
       ```js
       // astro.config.mjs
       import node from '@astrojs/node';
       import canvas from '@drupal-canvas/headless-astro/integration';
       import { defineConfig } from 'astro/config';
   
       export default defineConfig({
         output: 'server',
         adapter: node({ mode: 'standalone' }),
         integrations: [canvas()],
       });
       ```
   
       There are no route files to write. The integration injects all four Canvas routes for you, registers the frame-headers middleware, bundles the SDK into the SSR build, bridges `CANVAS_SITE_URL` from Astro's `.env` into the environment the SDK core reads, and generates the component manifest before compilation starts. A malformed `component.yml` fails the build rather than shipping a silently broken registry.
   
       That leaves the catch-all:
   
       ```astro
       ---
       // src/pages/[...slug].astro
       import CanvasComponentTree from '@drupal-canvas/headless-astro/CanvasComponentTree.astro';
       import { fetchPage } from '@drupal-canvas/headless-astro';
   
       const { slug } = Astro.params;
       const path = `/${(slug ?? '').split('/').map(encodeURIComponent).join('/')}`;
       const page = await fetchPage(Astro, path);
   
       if (!page) {
         Astro.response.status = 404;
       }
       ---
   
       {page && <CanvasComponentTree tree={page.content} />}
       ```
   
       Astro's `fetchPage()` takes the `Astro` global as its first argument, because Astro exposes cookies per request rather than through a request-scoped global the SDK could reach on its own. Pages that show draft content must not be prerendered.
     </TabItem>
     <TabItem label="Nuxt">
       ```bash
       npm install @drupal-canvas/headless @drupal-canvas/headless-nuxt
       ```
   
       Add the module. The adapter ships TypeScript source, so Nitro needs permission to transform it even though it lives in `node_modules`:
   
       ```ts
       // nuxt.config.ts
       export default defineNuxtConfig({
         modules: ['@drupal-canvas/headless-nuxt'],
         nitro: {
           esbuild: {
             options: {
               exclude: /node_modules\/(?!@drupal-canvas\/headless)/,
             },
           },
         },
       });
       ```
   
       Like Astro, there are no route files to write. The module mounts the Canvas routes as Nitro server handlers, merges the frame-headers directive into every response, registers `<CanvasComponentTree>` and `<DraftSession>` as global components, refreshes the app's async data after Canvas auto-saves, and generates the component manifest at build time.
   
       Content is fetched in a Nitro route, because the draft session's cookies are read server-side:
   
       ```ts
       // server/api/page/[...path].get.ts
       import { fetchPage } from '@drupal-canvas/headless-nuxt/server';
   
       export default defineEventHandler(async (event) => {
         const rawPath = getRouterParam(event, 'path') ?? '';
         const path = `/${rawPath.split('/').map(encodeURIComponent).join('/')}`;
         const page = await fetchPage(event, path);
   
         if (!page) {
           throw createError({ statusCode: 404, statusMessage: 'Not found' });
         }
         return page;
       });
       ```
   
       The page component reads that route. `<CanvasComponentTree>` needs no import, since the module registered it:
   
       ```vue
       <!-- app/pages/[...slug].vue -->
       <script setup lang="ts">
       const route = useRoute();
       const { data: page } = await useFetch(`/api/page${route.path}`);
       </script>
   
       <template>
         <CanvasComponentTree v-if="page" :tree="page.content" />
       </template>
       ```
     </TabItem>
     <TabItem label="TanStack Start">
       ```bash
       npm install @drupal-canvas/headless @drupal-canvas/headless-react @drupal-canvas/headless-tanstack-start
       ```

       Add the Vite plugin to the config the app already has: one import, and `canvas()` before `tanstackStart()` in `plugins`, every other plugin and option kept. `canvas()` compiles the SDK packages into the SSR build, bridges `CANVAS_SITE_URL` from the project's `.env` into the environment the SDK core reads, generates the component manifest at build time, and keeps the component registry current in development. A malformed `component.yml` fails the build rather than shipping a silently broken registry:

       ```ts
       // vite.config.ts: add the import, and canvas() before tanstackStart()
       import { canvas } from '@drupal-canvas/headless-tanstack-start/vite';
       // … the existing imports stay

       export default defineConfig({
         plugins: [canvas(), tanstackStart(), viteReact()],
       });
       ```

       The adapter ships TypeScript source, and `vite.config.ts` now imports some of it, so the Vite scripts need the runner config loader; without it the dev server exits with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`:

       ```json
       {
         "scripts": {
           "dev": "vite dev --port 3000 --configLoader runner",
           "build": "vite build --configLoader runner",
           "preview": "vite preview --configLoader runner"
         }
       }
       ```

       The frame headers ride on the app's global request middleware rather than on the plugin, because `createStart`'s configuration is isomorphic and the server helpers must stay out of the client bundle. If the app already has a `src/start.ts`, add `cspMiddleware` to its `requestMiddleware`; otherwise create it:

       ```ts
       // src/start.ts
       import { cspMiddleware } from '@drupal-canvas/headless-tanstack-start/middleware';
       import { createStart } from '@tanstack/react-start';

       export const startInstance = createStart(() => ({
         requestMiddleware: [cspMiddleware],
       }));
       ```

       The middleware's header lands on rendered pages, not on the Canvas API routes' responses: the `401` you check in step 3 carries no `content-security-policy` line, unlike the same check on the other three adapters. The check does not depend on that header.

       Like Next.js, you mount the Canvas routes yourself: five route files, each a few lines. The paths of the first, the fourth, and the fifth are fixed by the integration contract, so put them exactly there; the renew and exit paths are the SDK's defaults. The first three are the draft session (activation, in-place renewal, and the exit, a `POST` for the same prefetch reason as on Next.js):

       ```ts
       // src/routes/api/draft.ts
       import { createDraftRouteHandlers } from '@drupal-canvas/headless-tanstack-start';
       import { createFileRoute } from '@tanstack/react-router';

       const { draft } = createDraftRouteHandlers();

       export const Route = createFileRoute('/api/draft')({
         server: { handlers: { GET: draft.GET } },
       });
       ```

       ```ts
       // src/routes/api/draft.renew.ts
       import { createDraftRouteHandlers } from '@drupal-canvas/headless-tanstack-start';
       import { createFileRoute } from '@tanstack/react-router';

       const { draftRenew } = createDraftRouteHandlers();

       export const Route = createFileRoute('/api/draft/renew')({
         server: { handlers: { POST: draftRenew.POST } },
       });
       ```

       ```ts
       // src/routes/api/disable-draft.ts
       import { createDraftRouteHandlers } from '@drupal-canvas/headless-tanstack-start';
       import { createFileRoute } from '@tanstack/react-router';

       const { disableDraft } = createDraftRouteHandlers();

       export const Route = createFileRoute('/api/disable-draft')({
         server: { handlers: { POST: disableDraft.POST } },
       });
       ```

       The fourth is the component metadata endpoint, whose `OPTIONS` handler answers the editor's CORS preflight, and the fifth serves the editor's isolated component preview:

       ```ts
       // src/routes/api/canvas.components.ts
       import { createComponentMetadataHandlers } from '@drupal-canvas/headless-tanstack-start';
       import { createFileRoute } from '@tanstack/react-router';

       const { GET, OPTIONS } = createComponentMetadataHandlers();

       export const Route = createFileRoute('/api/canvas/components')({
         server: { handlers: { GET, OPTIONS } },
       });
       ```

       ```tsx
       // src/routes/api/canvas.component-preview.tsx
       import ComponentPreview, {
         loadComponentPreview,
       } from '@drupal-canvas/headless-tanstack-start/ComponentPreview';
       import { createFileRoute, notFound } from '@tanstack/react-router';

       export const Route = createFileRoute('/api/canvas/component-preview')({
         loader: async () => (await loadComponentPreview()) ?? notFound(),
         component: () => <ComponentPreview {...Route.useLoaderData()} />,
       });
       ```

       Last, the catch-all. `fetchPage()` reads the draft session from the request's cookies through TanStack Start's server-only helpers, and route loaders are isomorphic, so the loader reaches it through a server function whose handler lives in a `.server.ts` module, which the build keeps out of the client bundle:

       ```ts
       // src/server/canvas.server.ts
       import { fetchPage } from '@drupal-canvas/headless-tanstack-start';

       import type { PageResult } from '@drupal-canvas/headless-tanstack-start';

       export function readPageForPath(path: string): Promise<PageResult | null> {
         return fetchPage(path);
       }
       ```

       ```ts
       // src/server/canvas.functions.ts
       import { createServerFn } from '@tanstack/react-start';

       import { readPageForPath } from './canvas.server';

       export const getPageForPath = createServerFn()
         .validator((path: string) => path)
         .handler(({ data }) => readPageForPath(data));
       ```

       ```tsx
       // src/routes/$.tsx
       import CanvasComponentTree from '@drupal-canvas/headless-tanstack-start/CanvasComponentTree';
       import { toTanStackHead } from '@drupal-canvas/headless-tanstack-start/head';
       import { createFileRoute, notFound, redirect } from '@tanstack/react-router';

       import { getPageForPath } from '../server/canvas.functions';

       export const Route = createFileRoute('/$')({
         loader: async ({ params }) => {
           const path = `/${(params._splat ?? '')
             .split('/')
             .map(encodeURIComponent)
             .join('/')}`;
           const result = await getPageForPath({ data: path });
           if (!result) {
             throw notFound();
           }
           if ('redirect' in result) {
             throw redirect({
               href: result.redirect.url,
               statusCode: result.redirect.statusCode,
             });
           }
           return { page: result };
         },
         head: ({ loaderData }) =>
           loaderData ? toTanStackHead(loaderData.page.head) : {},
         component: CanvasPage,
       });

       function CanvasPage() {
         const { page } = Route.useLoaderData();
         return <CanvasComponentTree tree={page.content} />;
       }
       ```

       The route's `head` callback carries Drupal's document head (title, meta) into the rendered page, and a redirect Drupal resolves becomes the router's own. `fetchPage()` stays a per-route call here too: routes the app already owns keep rendering themselves.
     </TabItem>
   </Tabs>

3. ### Check the Canvas routes

   Start the dev server and run the `curl` check from the quickstart's [start-the-app step](/source-cms/get-content/quickstart/#start-the-app-and-check-the-canvas-routes). A `401` carrying `WWW-Authenticate: Bearer` means the adapter is live and the app is ready to register.

</Steps>

Components come next, and you may already have some. `npx canvas pull` brings components that exist on your site into the app's codebase, in the format the quickstart's [component scaffold step](/source-cms/get-content/quickstart/#add-a-component-of-your-own) writes; the [Canvas CLI reference](/source-cms/reference/canvas-cli/) covers it. Existing app components become Canvas components by gaining a `component.yml` beside them.

## When something goes wrong

**The registered row shows `Setup needed` after wiring**: a server answered at that URL, but not with the adapter's `401`. The usual cause here is an adapter that never reached the build: `withCanvas()` missing from `next.config.ts`, `canvas()` missing from `astro.config.mjs`'s `integrations`, `@drupal-canvas/headless-nuxt` missing from `nuxt.config.ts`'s `modules`, or `canvas()` missing from `vite.config.ts`'s `plugins` for TanStack Start. Re-run the `curl` check after fixing the wiring; the row re-probes on its own.

## Next steps

- [Register the app with Canvas](/source-cms/get-content/quickstart/#register-the-app-with-canvas): the quickstart's registration, preview, and component steps run unchanged from here.
- [Canvas CLI reference](/source-cms/reference/canvas-cli/): `canvas pull`, `canvas push`, and the component schema.
