First fetch
Goal: render real content from your site in a local app, fetched server-side over JSON:API.
What you’ll have when you’re done
Section titled “What you’ll have when you’re done”- The app from the Canvas Headless quickstart, or any app of your own, reading your site with the credentials from the auth quickstart.
- One server-side data module fetching the
articlecontent type with the Drupal API Client. - Your site’s article titles rendering in a dev server at localhost.
Prerequisites
Section titled “Prerequisites”- The app from the Canvas Headless quickstart, or any Next.js, Astro, Nuxt, or TanStack Start app of your own, running locally.
- The
.envfile from the auth quickstart: the API client it creates, reused here verbatim (no Source CMS site? a self-serve Cloud Platform free trial gets you building on Acquia the same day, on the Cloud Platform path; for a Source CMS site, request a Source demo) - At least one published entry of a content type on your site
Pick your framework. The tab you choose here stays selected on every other page.
The code samples fetch the article content type. If your site’s type is named differently, swap its machine name into the node--<type> string in step 2; everything else stays the same. (Your type’s machine name: the node--<type> links in the API root you called at the end of the auth quickstart.)
-
Add your credentials to
Section titled “Add your credentials to .env”.envAdd the same three variable names as the auth quickstart to the
.envyour app already has (in an app from the Canvas Headless quickstart, they join theCANVAS_SITE_URLthe scaffolder wrote there):.env DRUPAL_SITE_URL=https://your-site.example.comDRUPAL_CLIENT_ID=9f8e7d6c-0000-0000-0000-000000000000DRUPAL_CLIENT_SECRET=your-client-secretNext.js loads
.envautomatically. Because none of these names start withNEXT_PUBLIC_, they exist only on the server: the secret never reaches the browser.Astro loads
.envautomatically. Because none of these names start withPUBLIC_, they exist only in server code: the secret never reaches the browser.Nuxt loads
.envautomatically in development. You’ll read the values withprocess.envinside a server route, so the secret never reaches the browser.TanStack Start loads
.envthrough Vite in development. None of these names carries theVITE_prefix, so none of them reachesimport.meta.envin the browser bundle; you’ll read them withprocess.envinside a server module, so the secret never reaches the browser.Whatever the app already uses for configuration works the same way (its existing
.env,.env.local, or your secret manager). Don’t rename the three names to match local conventions: they are used verbatim everywhere, error messages included. In a monorepo they belong to the app that runs the fetch, not the repo root, unless your tooling already hoists env files. -
Add the fetch
Section titled “Add the fetch”Install the Drupal API Client:
Terminal window npm install @drupal-api-client/json-api-clientCreate
lib/acquia.ts, creating the folder if your app doesn’t have one. The client exchanges your credentials for an OAuth 2.0 access token behind the scenes and refreshes it when it expires; the module just asks for the articles:lib/acquia.ts import { JsonApiClient } from '@drupal-api-client/json-api-client';export const client = new JsonApiClient(process.env.DRUPAL_SITE_URL!, {apiPrefix: 'api', // Source CMS serves JSON:API at /apiauthentication: {type: 'OAuth',credentials: {grantType: 'client_credentials',clientId: process.env.DRUPAL_CLIENT_ID!,clientSecret: process.env.DRUPAL_CLIENT_SECRET!,},},});export type Article = {id: string;attributes: { title: string; created: string };};type ArticlesDocument = {data: Article[];errors?: { status: string; title: string }[];};export async function getArticles(): Promise<Article[]> {const doc = (await client.getCollection<ArticlesDocument>('node--article',)) as ArticlesDocument;if (doc.errors?.length) {const { status, title } = doc.errors[0];throw new Error(`Content request failed: ${status} ${title}`);}return doc.data;}Create
src/lib/acquia.ts. The client exchanges your credentials for an OAuth 2.0 access token behind the scenes and refreshes it when it expires; the module just asks for the articles:src/lib/acquia.ts import { JsonApiClient } from'@drupal-api-client/json-api-client';export const client = new JsonApiClient(import.meta.env.DRUPAL_SITE_URL, {apiPrefix: 'api', // Source CMS serves JSON:API at /apiauthentication: {type: 'OAuth',credentials: {grantType: 'client_credentials',clientId: import.meta.env.DRUPAL_CLIENT_ID,clientSecret: import.meta.env.DRUPAL_CLIENT_SECRET,},},});export type Article = {id: string;attributes: { title: string; created: string };};type ArticlesDocument = {data: Article[];errors?: { status: string; title: string }[];};export async function getArticles(): Promise<Article[]> {const doc = (await client.getCollection<ArticlesDocument>('node--article',)) as ArticlesDocument;if (doc.errors?.length) {const { status, title } = doc.errors[0];throw new Error(`Content request failed: ${status} ${title}`,);}return doc.data;}Create
server/api/articles.get.ts(a Nitro server route that runs only on the server). The client exchanges your credentials for an OAuth 2.0 access token behind the scenes and refreshes it when it expires; the route just asks for the articles:server/api/articles.get.ts import { JsonApiClient } from'@drupal-api-client/json-api-client';export const client = new JsonApiClient(process.env.DRUPAL_SITE_URL!, {apiPrefix: 'api', // Source CMS serves JSON:API at /apiauthentication: {type: 'OAuth',credentials: {grantType: 'client_credentials',clientId: process.env.DRUPAL_CLIENT_ID!,clientSecret: process.env.DRUPAL_CLIENT_SECRET!,},},});type Article = {id: string;attributes: { title: string; created: string };};type ArticlesDocument = {data: Article[];errors?: { status: string; title: string }[];};export default defineEventHandler(async () => {const doc = (await client.getCollection<ArticlesDocument>('node--article',)) as ArticlesDocument;if (doc.errors?.length) {const { status, title } = doc.errors[0];throw createError({statusCode: Number(status),statusMessage: `Content request failed: ${title}`,});}return doc.data;});Create
src/lib/acquia.server.ts. The.server.tssuffix is wiring, not naming: the build refuses that import anywhere it would reach the browser, which is what keeps the credentials out of the bundle. The client exchanges your credentials for an OAuth 2.0 access token behind the scenes and refreshes it when it expires; the module just asks for the articles:src/lib/acquia.server.ts import { JsonApiClient } from'@drupal-api-client/json-api-client';export const client = new JsonApiClient(process.env.DRUPAL_SITE_URL!, {apiPrefix: 'api', // Source CMS serves JSON:API at /apiauthentication: {type: 'OAuth',credentials: {grantType: 'client_credentials',clientId: process.env.DRUPAL_CLIENT_ID!,clientSecret: process.env.DRUPAL_CLIENT_SECRET!,},},});export type Article = {id: string;attributes: { title: string; created: string };};type ArticlesDocument = {data: Article[];errors?: { status: string; title: string }[];};export async function getArticles(): Promise<Article[]> {const doc = (await client.getCollection<ArticlesDocument>('node--article',)) as ArticlesDocument;if (doc.errors?.length) {const { status, title } = doc.errors[0];throw new Error(`Content request failed: ${status} ${title}`,);}return doc.data;}Route loaders are isomorphic: one runs on the server for the first request and in the browser for every client-side navigation after it, so a route may not call that module. Reach it through a server function instead, which the build replaces with an RPC stub in the browser bundle:
src/lib/acquia.functions.ts import { createServerFn } from '@tanstack/react-start';import { getArticles } from './acquia.server';export const listArticles = createServerFn().handler(() => getArticles(),);One line deserves a word:
getCollectionis generic and returnsT | RawApiResponseWithData<T>, so the call needs both the type argument and theas ArticlesDocumentcast. Without themdocisunknown, and every property access on it is a compile error under a--strictTypeScript config.The manual version of that token exchange is in the auth quickstart’s JavaScript tab, if you want to see what the client automates.
The module is the only file that knows Acquia exists, so any importable path works: put it wherever your shared server code lives (
lib/,src/lib/, whatever your project uses). Import it from server code only: a Server Component or route handler in Next.js, frontmatter or an endpoint in Astro, a server function or server route in TanStack Start, never a"use client"file or a browser<script>. In an app with its own/apiroutes, namespace the Nuxt server route (server/api/acquia/articles.get.ts) so it can’t collide with one the app already serves, and call that path in step 3. -
Render it
Section titled “Render it”A complete page rendering the list; the fetch runs on the server, never in the browser:
app/page.tsx import { getArticles } from '../lib/acquia';export default async function Home() {const articles = await getArticles();return (<main><h1>Articles</h1>{articles.length === 0 ? (<p>No articles yet.</p>) : (<ul>{articles.map((a) => (<li key={a.id}>{a.attributes.title}</li>))}</ul>)}</main>);}A complete page rendering the list; the frontmatter code runs on the server, never in the browser:
src/pages/index.astro ---import { getArticles } from '../lib/acquia';const articles = await getArticles();---<html lang="en"><head><meta charset="utf-8" /><title>Articles</title></head><body><main><h1>Articles</h1>{articles.length === 0 ? (<p>No articles yet.</p>) : (<ul>{articles.map((a) => (<li>{a.attributes.title}</li>))}</ul>)}</main></body></html>A complete page calling your server route with
useFetch, so credentials stay on the server:app/app.vue <script setup lang="ts">type Article = {id: string;attributes: { title: string; created: string };};const { data: articles } =await useFetch<Article[]>('/api/articles');</script><template><main><h1>Articles</h1><p v-if="!articles || articles.length === 0">No articles yet.</p><ul v-else><liv-for="a in articles":key="a.id">{{ a.attributes.title }}</li></ul></main></template>A complete route file. The loader calls the server function, so the fetch runs on the server for the first request and, on a client-side navigation, on the server again over the RPC bridge. The
Articletype comes straight from the server module: animport typeis erased before bundling, so it passes the guard that would refuse the same import as a value.src/routes/articles/index.tsx import { createFileRoute } from '@tanstack/react-router';import { listArticles } from '../../lib/acquia.functions';import type { Article } from '../../lib/acquia.server';export const Route = createFileRoute('/articles/')({loader: () => listArticles(),component: ArticlesPage,});function ArticlesPage() {const articles: Article[] = Route.useLoaderData();return (<main><h1>Articles</h1>{articles.length === 0 ? (<p>No articles yet.</p>) : (<ul>{articles.map((a) => (<li key={a.id}>{a.attributes.title}</li>))}</ul>)}</main>);}The samples are complete front-page files. In the app from the Canvas Headless quickstart, give the list a route of its own instead of replacing a page the site renders (only the paths handed to
fetchPage()are Canvas’s). In any app, the diff into a page you already serve is an import, oneawait, and the list; nothing else about the page changes. -
Run the dev server
Section titled “Run the dev server”Start the app the way it always starts (
npm run devin a scaffolded app;pnpm dev,make dev, whatever your project uses) and open the page with the list. Your site’s article titles render. On Next.js, a- Environments: .envline in the startup output is your confirmation the credentials file loaded.
Give your agent this page’s Markdown export (the View as Markdown control at the top) so it has the complete file contents, keep your three credential values at hand, and hand it the goal:
Add a server-side fetch of my Source CMS site's article content type to this app:put DRUPAL_SITE_URL, DRUPAL_CLIENT_ID and DRUPAL_CLIENT_SECRET in the app's .env, install@drupal-api-client/json-api-client, add one server-side data module that creates aJsonApiClient with the client_credentials OAuth grant from those env vars andapiPrefix "api", fetch node--article with getCollection, and render the article titleson a route of its own. Never import the module from client-side code.When the agent finishes, open the route it added: your site’s article titles render. Connected over MCP, your agent can also inspect the site’s content types itself (connect an AI agent).
What just happened
Section titled “What just happened”Your app made two server-side requests, both through the Drupal API Client. On the first call it exchanged the API client’s ID and secret for an OAuth 2.0 access token at $DRUPAL_SITE_URL/oauth/token (the client_credentials grant, exactly as in the auth quickstart). It caches that token and refreshes it when it expires. Then getCollection("node--article") sent GET $DRUPAL_SITE_URL/api/node/article with the bearer token. The response’s data array holds one object per article (type, id, and the fields under attributes), and the page rendered attributes.title from each. Because everything runs on the server, DRUPAL_CLIENT_SECRET never ships to the browser.
When something goes wrong
Section titled “When something goes wrong”Error: Could not authenticate with the provided credentials.
the client ID or secret is wrong, or DRUPAL_SITE_URL points at a different environment than the one that issued your API client. Re-copy both values from API > API clients (if the secret is lost, create a new API client, since the secret is shown only once), confirm all three .env values come from the same site, then restart the dev server. More cases in the auth guide’s troubleshooting.
Error: Content request failed: 403 Forbidden
the entry exists but this token may not read it. This is a Source CMS site’s answer; stock Drupal surfaces the same situation as an empty data array with a meta.omitted block, not a 403. A scope is not the usual cause here: scopes gate writes and admin surfaces, not published-content reads, so a valid token reads published content whatever its scopes. A 403 on this path almost always means the entry is unpublished, which needs content:administer. If the entry really is published, check that the API client’s user account has permission to view it.
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
the client received an HTML page where it expected JSON. Two causes, both in lib/acquia.ts: the JsonApiClient was constructed without apiPrefix: 'api' (its default base path is /jsonapi; Source CMS serves /api), or the type in getCollection('node--<type>') doesn’t exist on the site, which returns an HTML 404. For the latter, call the JSON:API root as in step 3 of the auth quickstart and look for node--<type> links; use an existing machine name.
Error: baseUrl is required, TypeError: fetch failed (cause: getaddrinfo ENOTFOUND), or TypeError: Invalid URL
the environment variables never loaded. Confirm the file is named exactly .env, sits in the project root (next to package.json), and restart the dev server after editing it. In an app that already existed, three more causes: the framework loads a different file than the one you edited (Next.js reads .env.local over .env, so check which one your team uses), the app sits in a monorepo and the file is at the wrong level, or the project validates environment variables through a schema (a zod-based env.ts is common) that silently strips names it doesn’t know; add the three variables to the schema.
401 Unauthorized in an existing app, with credentials you know are right
a shared HTTP client is interfering. Apps with their own fetch layer (an axios instance, an ofetch wrapper) often inject an Authorization header or a base URL through interceptors, overwriting the bearer token. The fix is the module’s design: the Drupal API Client issues its own requests, independent of your app’s fetch layer. Don’t refactor it to go through the shared client.
404 on your server route, or the response is your app’s HTML instead of JSON
something already owns that path. Existing rewrites (Next.js rewrites()), a dev-server proxy for /api, or catch-all middleware can capture the route before it reaches the module. Exclude the route’s prefix from the proxy or rewrite rules, or move it to a prefix nothing else claims (server/api/acquia/articles.get.ts).
…has been blocked by CORS policy in the browser console
the module got imported into client-side code, so the browser is now calling the CMS directly. That’s the one thing it must never do: the credentials would ship with it. Move the call back to server code (Server Component, frontmatter, or server route) and let the browser talk only to your own origin.
The page renders “No articles yet.” with no error
the API answered 200 with an empty data array. Two things to verify: DRUPAL_SITE_URL points at the site and environment you expect (open it in a browser and check the content there), and the site has at least one article whose status is Published (unpublished entries are omitted from the response). The response body tells you which case you’re in: a meta.count greater than zero alongside a meta.omitted block means the articles exist but are unpublished or access-restricted, not missing. A stock Drupal site sends no meta.count at all; there, the meta.omitted block alone is the signal.
Next steps
Section titled “Next steps”- Canvas Headless quickstart: if you skipped it, have the Drupal Canvas editor render its preview through this app, with your components placeable on pages by editors; Wire an app you already have replaces its scaffold step.
- Query by type, field, and relationship: filter, sort, and paginate what you just fetched.
- Fetch content directly: where the fetch should live, caching, and mapping responses to components.
- Render content: detail pages, rich text, images, and pagination on top of this list.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)