# First fetch

**Goal:** render real content from your [site](/start-here/glossary/#site) in a local app, fetched server-side over [JSON:API](/start-here/glossary/#jsonapi).

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

- The app from the [Canvas Headless quickstart](/source-cms/get-content/quickstart/), or any app of your own, reading your site with the credentials from the auth quickstart.
- One server-side data module fetching the `article` [content type](/start-here/glossary/#content-type-bundle) with the [Drupal API Client](/start-here/glossary/#drupal-api-client).
- Your site's article titles rendering in a dev server at localhost.

## Prerequisites

- The app from the Canvas Headless quickstart, or any Next.js, Astro, Nuxt, or TanStack Start app of your own, running locally.
- The `.env` file from the [auth quickstart](/source-cms/authenticate/quickstart/): the [API client](/start-here/glossary/#api-client) it creates, reused here verbatim (no Source CMS 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, on the Cloud Platform path; for a Source CMS site, <a href="https://www.acquia.com/request-a-demo/acquia-source" target="_blank" rel="noopener">request a Source demo</a>)
- At least one published entry of a content type on your site

## Steps

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](/start-here/glossary/#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](/source-cms/authenticate/quickstart/).)

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

<Steps>

1. ### Add your credentials to `.env`

   Add the same three variable names as the [auth quickstart](/source-cms/authenticate/quickstart/) to the `.env` your app already has (in an app from the [Canvas Headless quickstart](/source-cms/get-content/quickstart/), they join the `CANVAS_SITE_URL` the scaffolder wrote there):

   ```bash
   # .env
   DRUPAL_SITE_URL=https://your-site.example.com
   DRUPAL_CLIENT_ID=9f8e7d6c-0000-0000-0000-000000000000
   DRUPAL_CLIENT_SECRET=your-client-secret
   ```

   <Tabs syncKey="framework">

   <TabItem label="Next.js">

   Next.js loads `.env` automatically. Because none of these names start with `NEXT_PUBLIC_`, they exist only on the server: the secret never reaches the browser.

   </TabItem>

   <TabItem label="Astro">

   Astro loads `.env` automatically. Because none of these names start with `PUBLIC_`, they exist only in server code: the secret never reaches the browser.

   </TabItem>

   <TabItem label="Nuxt">

   Nuxt loads `.env` automatically in development. You'll read the values with `process.env` inside a server route, so the secret never reaches the browser.

   </TabItem>

   <TabItem label="TanStack Start">

   TanStack Start loads `.env` through Vite in development. None of these names carries the `VITE_` prefix, so none of them reaches `import.meta.env` in the browser bundle; you'll read them with `process.env` inside a server module, so the secret never reaches the browser.

   </TabItem>

   </Tabs>

   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.

2. ### Add the fetch

   Install the [Drupal API Client](/start-here/glossary/#drupal-api-client):

   ```bash
   npm install @drupal-api-client/json-api-client
   ```

   <Tabs syncKey="framework">

   <TabItem label="Next.js">

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

   ```ts
   // 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 /api
     authentication: {
       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;
   }
   ```

   </TabItem>

   <TabItem label="Astro">

   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:

   ```ts
   // 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 /api
     authentication: {
       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;
   }
   ```

   </TabItem>

   <TabItem label="Nuxt">

   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:

   ```ts
   // 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 /api
     authentication: {
       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;
   });
   ```

   </TabItem>

   <TabItem label="TanStack Start">

   Create `src/lib/acquia.server.ts`. The `.server.ts` suffix 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:

   ```ts
   // 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 /api
     authentication: {
       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:

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

   import { getArticles } from './acquia.server';

   export const listArticles = createServerFn().handler(
     () => getArticles(),
   );
   ```

   </TabItem>

   </Tabs>

   One line deserves a word: `getCollection` is generic and returns `T | RawApiResponseWithData<T>`, so the call needs both the type argument and the `as ArticlesDocument` cast. Without them `doc` is `unknown`, and every property access on it is a compile error under a `--strict` TypeScript config.

   The manual version of that token exchange is in the [auth quickstart](/source-cms/authenticate/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 `/api` routes, 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.

3. ### Render it

   <Tabs syncKey="framework">

   <TabItem label="Next.js">

   A complete page rendering the list; the fetch runs on the server, never in the browser:

   ```tsx
   // 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>
     );
   }
   ```

   </TabItem>

   <TabItem label="Astro">

   A complete page rendering the list; the frontmatter code runs on the server, never in the browser:

   ```astro
   ---
   // 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>
   ```

   </TabItem>

   <TabItem label="Nuxt">

   A complete page calling your server route with `useFetch`, so credentials stay on the server:

   ```vue
   <!-- 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>
         <li
           v-for="a in articles"
           :key="a.id"
         >{{ a.attributes.title }}</li>
       </ul>
     </main>
   </template>
   ```

   </TabItem>

   <TabItem label="TanStack Start">

   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 `Article` type comes straight from the server module: an `import type` is erased before bundling, so it passes the guard that would refuse the same import as a value.

   ```tsx
   // 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>
     );
   }
   ```

   </TabItem>

   </Tabs>

   The samples are complete front-page files. In the app from the [Canvas Headless quickstart](/source-cms/get-content/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, one `await`, and the list; nothing else about the page changes.

4. ### Run the dev server

   Start the app the way it always starts (`npm run dev` in 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: .env` line in the startup output is your confirmation the credentials file loaded.

</Steps>

</TabItem>

<TabItem label="With an AI agent">

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:

```text
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 a
JsonApiClient with the client_credentials OAuth grant from those env vars and
apiPrefix "api", fetch node--article with getCollection, and render the article titles
on 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](/source-cms/ai-agents/quickstart/)).

</TabItem>
</Tabs>
## What just happened

Your app made two server-side requests, both through the [Drupal API Client](/start-here/glossary/#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](/source-cms/authenticate/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

**`Error: Could not authenticate with the provided credentials.`**: the client ID or secret is wrong, or `DRUPAL_SITE_URL` points at a different [environment](/start-here/glossary/#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](/source-cms/authenticate/guide/#when-something-goes-wrong).

**`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](/start-here/glossary/#scope) is not the usual cause here: [scopes gate writes and admin surfaces, not published-content reads](/source-cms/reference/authentication/#scopes), 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](/source-cms/authenticate/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

- [Canvas Headless quickstart](/source-cms/get-content/quickstart/): if you skipped it, have the [Drupal Canvas](/start-here/glossary/#drupal-canvas) editor render its preview through this app, with your components placeable on pages by editors; [Wire an app you already have](/source-cms/get-content/wire-an-existing-app/) replaces its scaffold step.
- [Query by type, field, and relationship](/source-cms/content-api/guide/): filter, sort, and paginate what you just fetched.
- [Fetch content directly](/source-cms/content-api/fetch-content/): where the fetch should live, caching, and mapping responses to components.
- [Render content](/source-cms/get-content/render-content/): detail pages, rich text, images, and pagination on top of this list.
