# Fetch content directly

**Goal:** turn [First fetch](/source-cms/content-api/first-fetch/)'s one working fetch into a pattern you can trust in a real app: knowing where the fetch runs, how long its result lives, and how responses become components.

Everything here applies to any Acquia-hosted JSON:API backend: a Source CMS [site](/start-here/glossary/#site) (the examples' default) or self-managed Drupal on Cloud Platform ([headless on Cloud Platform](/start-here/choose-your-backend/#headless-on-cloud-platform)). The one constant difference is the base path: Source serves `/api`, stock Drupal serves `/jsonapi`. Swap the path segment and read on.

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

- A reading of the request and response First fetch made, so nothing in it is unexplained.
- A fetch location chosen for your framework, and the trade-offs of the alternatives.
- Where a fetch runs when it lives in a Canvas component instead of your app, and what it can reach there.
- Token and content caching that survive more than one request.
- A mapping pattern that doesn't crash on missing fields.

## Prerequisites

- The working app from [First fetch](/source-cms/content-api/first-fetch/)
- Credentials and token basics from the [auth quickstart](/source-cms/authenticate/quickstart/)

## Steps

<Steps>

1. ### Know the request you're making

   Every content read First fetch made, and every one you'll make, has this shape:

   ```
   GET {DRUPAL_SITE_URL}/api/node/article
   Accept: application/vnd.api+json
   Authorization: Bearer <access token>
   ```

   The path is `/api/<entity type>/<bundle>`: `node` is the [entity type](/start-here/glossary/#entity-type), `article` the [content type](/start-here/glossary/#content-type-bundle). The token comes from the `client_credentials` grant; the [auth guide](/source-cms/authenticate/guide/) covers grant types and token storage.

   The response is a [JSON:API](/start-here/glossary/#jsonapi) document:

   ```json
   {
     "data": [
       {
         "type": "node--article",
         "id": "a3c9f2e1-0000-0000-0000-000000000000",
         "attributes": {
           "title": "Launch announcement",
           "created": "2026-05-11T09:30:00+00:00",
           "body": {
             "value": "<p>Raw input…</p>",
             "format": "filtered_html",
             "processed": "<p>Rendered output…</p>"
           }
         },
         "relationships": {
           "image": {
             "data": { "type": "media--image", "id": "b7d1…" }
           }
         }
       }
     ],
     "links": {
       "next": {
         "href": "…/api/node/article?page%5Boffset%5D=50"
       }
     }
   }
   ```

   Three things to internalize:

   - Every [field](/start-here/glossary/#field) appears under `attributes` with its [machine name](/start-here/glossary/#machine-name) verbatim.
   - References to other entities (media, taxonomy) live under `relationships`, and are fetched alongside the entry with the `include` query parameter (as in `?include=image`).
   - `links.next` appears when there are more entries than one page returns.

   Endpoint and document structure in full: the [Content API reference](/source-cms/reference/content-api/). Query parameters (`filter`, `sort`, `include`, `fields`, `page`): the [query parameters reference](/source-cms/reference/query-parameters/).

   The whole path stays on the server. The token-authenticated JSON:API call runs from your own server, never the browser, so `DRUPAL_CLIENT_SECRET` never ships to the client:

   <ConceptDiagram name="headless-request-path" height={360} />

2. ### Put the fetch where it belongs

   One rule is absolute: `DRUPAL_CLIENT_SECRET` must never appear in code that ships to the browser. Everything below keeps the fetch on the server. Each framework has one canonical place for it:

   <Tabs syncKey="framework">

   <TabItem label="Next.js">

   Fetch in Server Components. An async component calls the data module directly: no API route needed for your own pages.

   ```tsx
   // app/articles/page.tsx
   import { getArticles } from '../../lib/acquia';

   export default async function ArticlesPage() {
     const articles = await getArticles();
     return (
       <ul>
         {articles.map((a) => (
           <li key={a.id}>{a.attributes.title}</li>
         ))}
       </ul>
     );
   }
   ```

   When the alternatives apply: add a Route Handler (`app/api/…/route.ts`) that wraps the same data module only when browser code needs to refetch after load (search-as-you-type, polling). The handler keeps credentials server-side while the client calls your own origin. Never fetch the CMS from a Client Component: `"use client"` code is shipped to the browser, and the credentials would go with it.

   </TabItem>

   <TabItem label="Astro">

   Fetch in component frontmatter, exactly as First fetch did:

   ```astro
   ---
   import { getArticles } from '../lib/acquia';

   const articles = await getArticles();
   ---

   <ul>
     {articles.map((a) => <li>{a.attributes.title}</li>)}
   </ul>
   ```

   When this runs depends on your output mode: with static output (the default) the fetch runs once at build time and the result is baked into HTML; with an [SSR](/start-here/glossary/#ssr) adapter (`npx astro add node`) it runs on the server per request.

   When the alternatives apply: add an endpoint (`src/pages/api/articles.json.ts`) wrapping the same data module only when browser code needs fresh data after load. Client-side scripts must never call the CMS directly: they'd need the credentials.

   </TabItem>

   <TabItem label="Nuxt">

   Fetch in a Nitro server route and consume it with `useFetch`, exactly as First fetch did:

   ```vue
   <script setup lang="ts">
   const { data: articles } = await useFetch('/api/articles');
   </script>
   ```

   `useFetch` runs the request on the server during SSR and transfers the result to the client in the payload, so the CMS is not hit again on hydration.

   When the alternatives apply: `useAsyncData` with a direct `$fetch` to the CMS is tempting but wrong here: during client-side navigation it executes in the browser, which would require exposing credentials. Keep the CMS call inside `server/`, where `process.env` and the secret are safe, and let pages talk only to your own `/api/*` routes.

   </TabItem>

   <TabItem label="TanStack Start">

   Fetch in a server function, called from the route's loader, exactly as First fetch did:

   ```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 (
       <ul>
         {articles.map((a) => (
           <li key={a.id}>{a.attributes.title}</li>
         ))}
       </ul>
     );
   }
   ```

   A loader is not itself a server-only place: it runs on the server for the first request and in the browser for every client-side navigation after it. The server function is the boundary, and the `.server.ts` suffix on the data module is what makes the boundary enforced rather than remembered.

   When the alternatives apply: add a server route (a route file exporting only `server: { handlers }`) wrapping the same fetch when the caller is not a route of yours, such as a webhook receiver or browser code refetching after load. Never import the data module from a route or a component; a module without the suffix compiles fine and ships its client construction, `client_credentials` grant and all, into the browser bundle.

   </TabItem>

   </Tabs>

3. ### Fetch from a Canvas code component

   The other place a direct fetch runs is inside a [component](/start-here/glossary/#component) on a [Drupal Canvas](/start-here/glossary/#drupal-canvas) page, when the site renders [in-platform](/start-here/glossary/#in-platform-rendering) rather than through an app of yours. Everything above assumed your own server; a Canvas component has none. It arrives on the served page as a client-rendered [island](/start-here/glossary/#island), so its fetch runs in the visitor's browser, against the site's own origin.

   The component environment ships the fetching toolkit as built-in imports, with no install step: `useSWR` (the `swr` package's default export), `DrupalJsonApiParams` from `drupal-jsonapi-params`, and `JsonApiClient` from the `drupal-canvas` package. That client is the [Drupal API Client](/start-here/glossary/#drupal-api-client), preconfigured with the site's URL and API prefix (`/api` here, and no `apiPrefix` option to get wrong). The queries from the [querying guide](/source-cms/content-api/guide/) work unchanged, and while you build, each `useSWR` fetch's result shows in the `Data Fetch` pane under the component preview.

   Three things separate this context from your app's server code:

   - **The visitor's access, not a credential's.** The request carries the visitor's own session, which on a published page usually means no session at all: anonymous access, published and publicly readable content only. Nothing from the auth quickstart applies here. A component's compiled JavaScript is served to every visitor, so a credential placed in one is published with it; the component development guide covers [what may and may not ship in a component](/source-cms/canvas-components/guide/#style-with-tailwind-and-know-how-the-css-travels).
   - **The editor sees more than the visitor.** The component preview in the code editor runs with your session, so its fetch can return entries a visitor's never will, unpublished content included. A listing that looks complete in the `Data Fetch` pane can render shorter, or empty, on the published page. Check fetching components logged out before calling them done.
   - **The CDN answers repeat requests.** Anonymous API responses are CDN-cached per exact URL (the same behavior the stale-content troubleshooting entry below describes), so a component on a busy page does not multiply load on the site. A just-published entry can lag until that cache clears.

   Reach for a fetch only for content that is not the current page: a related-entries list, a menu, a feed. Content that is the page arrives through [props](/start-here/glossary/#prop) and slots, which editors control and which need no request at all; the [component development guide](/source-cms/canvas-components/guide/) covers declaring and receiving them. And when your own app is the renderer ([Canvas Headless](/start-here/glossary/#canvas-headless)), the components editors place are implemented in your codebase, so their data goes through the app's fetch layer from the previous step, not through this one.

4. ### Cache what you fetch, and revalidate it

   Two things are worth caching. The first, the token (it lives `expires_in` seconds, 300, so five minutes), the [Drupal API Client](/start-here/glossary/#drupal-api-client) already handles: it caches the token on the client instance and refreshes it shortly before expiry. That's why First fetch put the client in one shared module; every fetch in the app reuses the same instance and the same token. Nothing to write.

   Second, the content itself. Caching content means deciding how stale you can tolerate being, and how the cache gets refreshed (revalidated):

   <Tabs syncKey="framework">

   <TabItem label="Next.js">

   In current Next.js, `fetch` results are not cached by default, and the caching options ride on the `fetch` call itself. The client's `customFetch` option is the hook for that: it makes every request the client sends carry your revalidation window and a cache tag. Use the CMS's own tag names (`node_list:article` here), since those are the names it sends when content changes; a tag you invent has nothing to invalidate it. Add it to First fetch's `lib/acquia.ts` (verified: with this in place, a production build serves repeat requests with zero CMS fetches):

   ```ts
   const client = new JsonApiClient(
     process.env.DRUPAL_SITE_URL!,
     {
       apiPrefix: 'api',
       customFetch: (url, init) =>
         fetch(url as RequestInfo, {
           ...init,
           next: { revalidate: 300, tags: ['node_list:article'] },
         } as RequestInit),
       authentication: {
         // …as in First fetch…
       },
     },
   );
   ```

   Content is then at most 5 minutes stale; a [webhook](/source-cms/content-api/webhooks/) receiver narrows the window further by invalidating on publish.

   </TabItem>

   <TabItem label="Astro">

   With static output, caching is the build: content is frozen in HTML until the next build, which is the cheapest and fastest option when content changes hours apart. Revalidation means rebuilding: trigger builds from content changes with a [webhook](/start-here/glossary/#webhook) (see the [webhooks guide](/source-cms/content-api/webhooks/)). With an SSR adapter, each request refetches by default; the module-scope token cache above applies, and for content you can cache responses the same way (module-scope with a timestamp) or set CDN cache headers on the page.

   </TabItem>

   <TabItem label="Nuxt">

   Cache at the server-route boundary with Nitro route rules in `nuxt.config.ts`. Pages keep calling `/api/articles` and Nitro serves the cached result:

   ```ts
   export default defineNuxtConfig({
     routeRules: {
       '/api/articles': { cache: { maxAge: 300 } },
     },
   });
   ```

   Content is then at most 5 minutes stale, and the CMS sees one request per window regardless of traffic. For static-ish sites, rebuild-on-publish via a [webhook](/start-here/glossary/#webhook) works here too (see the [webhooks guide](/source-cms/content-api/webhooks/)).

   </TabItem>

   <TabItem label="TanStack Start">

   TanStack Start has no server-side data cache of its own. A route's `staleTime` caches loader data in the router, which lives in one visitor's browser and starts empty on every full page load, so it saves a repeat navigation and nothing else. Cache in the data module instead, where every request the server process handles shares one entry. Add it to First fetch's `src/lib/acquia.server.ts`:

   ```ts
   // src/lib/acquia.server.ts
   const TTL_MS = 300_000;
   let cached: { at: number; articles: Article[] } | null =
     null;
   let inFlight: Promise<Article[]> | null = null;

   export function getCachedArticles() {
     if (cached && Date.now() - cached.at < TTL_MS) {
       return Promise.resolve(cached.articles);
     }
     // One fetch, shared: without this, every request that
     // arrives on a cold cache starts its own.
     inFlight ??= getArticles()
       .then((articles) => {
         cached = { at: Date.now(), articles };
         return articles;
       })
       .finally(() => {
         inFlight = null;
       });
     return inFlight;
   }

   export function clearContentCache() {
     cached = null;
     inFlight = null;
   }
   ```

   The shared in-flight promise is the part that is easy to leave out and expensive to miss: without it, eight requests arriving together on a cold cache made eight fetches in a run here, and with it, one. Point the server function at `getCachedArticles` and content is at most 5 minutes stale, with the CMS seeing one request per window however much traffic arrives. The entry is per server process, so each instance behind a load balancer warms its own; a [webhook](/source-cms/content-api/webhooks/) receiver calling `clearContentCache()` closes the window on publish.

   </TabItem>

   </Tabs>

5. ### Map responses to components

   Resist passing raw JSON:API entries into your components. Map them once, at the data boundary, into the shape your UI wants. Two field facts drive the pattern:

   - `title` is required on every entry, but most other fields are optional and come back `null` (or absent, under sparse fieldsets) when an author left them empty.
   - Rich-text fields are objects whose `processed` property holds the HTML rendered through the [site](/start-here/glossary/#site)'s text format. Use it, not `value`, which is the author's raw input.

   ```ts
   // Extend First fetch's Article type with the fields
   // you map:
   export type Article = {
     id: string;
     attributes: {
       title: string;
       created: string;
       body?: { value: string; processed: string } | null;
     };
   };

   export type ArticleCard = {
     id: string;
     title: string;
     published: string;
     html: string;
   };

   export function toCard(a: Article): ArticleCard {
     return {
       id: a.id,
       title: a.attributes.title,
       published: new Date(
         a.attributes.created,
       ).toLocaleDateString(),
       html: a.attributes.body?.processed ?? '',
     };
   }
   ```

   The optional chaining is the point: an entry without a body becomes an empty string, not a crash. Related entities (`image` and friends) arrive separately in the document's `included` array when you request them with `?include=`; the [query parameters reference](/source-cms/reference/query-parameters/) covers resolving them.

</Steps>

## When something goes wrong

**Content updated in the CMS, but the old version still renders**: a cache is serving you. In Next.js, check the `next: { revalidate }` window in the client's `customFetch` (and remember the dev server caches less than production builds). In static Astro, nothing changes until the next build; confirm your rebuild webhook fired. In Nuxt, check `routeRules` cache windows, and remember `useFetch` reuses its payload during client-side navigation, so a hard reload tells you whether the staleness is the payload or the server route. One more layer sits on the CMS side: anonymous (tokenless, public-access) API responses are CDN-cached per exact URL, so an unauthenticated fetch can stay stale after a publish. Requests carrying an `Authorization` header bypass that cache, which is one more reason the token matters even on public sites.

**Responses are hundreds of kilobytes for a page that shows five titles**: you're over-fetching, by default every field of every entry in the page comes back. Request only what you map: `?fields[node--article]=title,created,body&page[limit]=10`. Sparse fieldsets and pagination are in the [query parameters reference](/source-cms/reference/query-parameters/); the [Content API guide](/source-cms/content-api/guide/) covers building queries.

**`TypeError: Cannot read properties of undefined (reading 'processed')`**: an entry is missing an optional field and the mapping assumed it exists. Add optional chaining with a fallback (`a.attributes.body?.processed ?? ''`) at the mapping boundary rather than in components.

**`401 Unauthorized` appearing only after the app has run for a while**: something is caching a token past its `expires_in`. The [Drupal API Client](/start-here/glossary/#drupal-api-client) refreshes its own token before expiry, so look for a hand-rolled cache around it, or a serverless platform freezing and thawing processes at odd times; more token failure modes in the [auth guide's troubleshooting](/source-cms/authenticate/guide/#when-something-goes-wrong).

## Next steps

- [Render content](/source-cms/get-content/render-content/): detail routes, rich text, images, and a paginated listing built on this pattern.
- [Query by type, field, and relationship](/source-cms/content-api/guide/): filters, sorting, and pagination on top of the fetch you now understand.
