Wire an app you already have
Goal: join an app you already have to Canvas Headless, reaching the same state the quickstart’s scaffold gives you: the app serving the Canvas contract, ready to register.
What you’ll have when you’re done
Section titled “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
401at the component metadata route, ready for the quickstart’s registration step and everything after it.
Prerequisites
Section titled “Prerequisites”- An existing Next.js, Astro, Nuxt, or TanStack Start app you can add dependencies to.
- Everything from the 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.
The 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.
-
Add the two shared files
Section titled “Add the two shared files”Two files are the same whatever the framework.
canvas.config.jsonat the project root tells component discovery where your components live:{"componentDir": "src/components","aliasBaseDir": ".","globalCssPath": "src/styles/global.css"}componentDiris the only key that has to match your project; the value above is the default. And.envholds the whole of the app’s Canvas configuration:Terminal window CANVAS_SITE_URL=https://your-site.example.com -
Install and wire the adapter
Section titled “Install and wire the adapter”Terminal window npm install @drupal-canvas/headless @drupal-canvas/headless-react @drupal-canvas/headless-nextWrap 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 theContent-Security-Policy: frame-ancestorsheader that keeps the app un-embeddable until a live preview session names the editor’s origin:next.config.ts import { withCanvas } from '@drupal-canvas/headless-next/config';export default withCanvas();Import it from
/configrather than the package root.next.config.tsis 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:
app/api/draft/route.ts import { createDraftRouteHandlers } from '@drupal-canvas/headless-next';export const GET = createDraftRouteHandlers().draft.GET;app/api/draft/renew/route.ts import { createDraftRouteHandlers } from '@drupal-canvas/headless-next';export const POST = createDraftRouteHandlers().draftRenew.POST;app/api/disable-draft/route.ts import { createDraftRouteHandlers } from '@drupal-canvas/headless-next';export const POST = createDraftRouteHandlers().disableDraft.POST;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
POSTon purpose: aGETreachable 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:
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.Terminal window npm install @drupal-canvas/headless @drupal-canvas/headless-astroAdd the integration. Draft preview is per-request, so the project also needs an SSR adapter and
output: 'server';npx astro add nodeinstalls@astrojs/nodeif the app is still static: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_URLfrom Astro’s.envinto the environment the SDK core reads, and generates the component manifest before compilation starts. A malformedcomponent.ymlfails the build rather than shipping a silently broken registry.That leaves the catch-all:
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 theAstroglobal 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.Terminal window npm install @drupal-canvas/headless @drupal-canvas/headless-nuxtAdd the module. The adapter ships TypeScript source, so Nitro needs permission to transform it even though it lives in
node_modules: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:
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: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>Terminal window npm install @drupal-canvas/headless @drupal-canvas/headless-react @drupal-canvas/headless-tanstack-startAdd the Vite plugin to the config the app already has: one import, and
canvas()beforetanstackStart()inplugins, every other plugin and option kept.canvas()compiles the SDK packages into the SSR build, bridgesCANVAS_SITE_URLfrom the project’s.envinto the environment the SDK core reads, generates the component manifest at build time, and keeps the component registry current in development. A malformedcomponent.ymlfails the build rather than shipping a silently broken registry:// vite.config.ts: add the import, and canvas() before tanstackStart()import { canvas } from '@drupal-canvas/headless-tanstack-start/vite';// … the existing imports stayexport default defineConfig({plugins: [canvas(), tanstackStart(), viteReact()],});The adapter ships TypeScript source, and
vite.config.tsnow imports some of it, so the Vite scripts need the runner config loader; without it the dev server exits withERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING:{"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 asrc/start.ts, addcspMiddlewareto itsrequestMiddleware; otherwise create it: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
401you check in step 3 carries nocontent-security-policyline, 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
POSTfor the same prefetch reason as on Next.js):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 } },});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 } },});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
OPTIONShandler answers the editor’s CORS preflight, and the fifth serves the editor’s isolated component preview: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 } },});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.tsmodule, which the build keeps out of the client bundle: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);}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));// src/routes/$.tsximport 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
headcallback 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. -
Check the Canvas routes
Section titled “Check the Canvas routes”Start the dev server and run the
curlcheck from the quickstart’s start-the-app step. A401carryingWWW-Authenticate: Bearermeans the adapter is live and the app is ready to register.
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 writes; the Canvas CLI reference covers it. Existing app components become Canvas components by gaining a component.yml beside them.
When something goes wrong
Section titled “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
Section titled “Next steps”- Register the app with Canvas: the quickstart’s registration, preview, and component steps run unchanged from here.
- Canvas CLI reference:
canvas pull,canvas push, and the component schema.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)