Skip to content

Fetch content directly

Goal: turn 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 (the examples’ default) or self-managed Drupal on Cloud Platform (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.

  • 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.
  1. 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, article the content type. The token comes from the client_credentials grant; the auth guide covers grant types and token storage.

    The response is a JSON:API document:

    {
    "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 appears under attributes with its 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. Query parameters (filter, sort, include, fields, page): the query parameters reference.

    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:

  2. 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:

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

    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.

  3. The other place a direct fetch runs is inside a component on a Drupal Canvas page, when the site renders in-platform 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, 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, preconfigured with the site’s URL and API prefix (/api here, and no apiPrefix option to get wrong). The queries from the querying 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.
    • 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 and slots, which editors control and which need no request at all; the component development guide covers declaring and receiving them. And when your own app is the renderer (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. Two things are worth caching. The first, the token (it lives expires_in seconds, 300, so five minutes), the 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):

    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):

    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 receiver narrows the window further by invalidating on publish.

  5. 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’s text format. Use it, not value, which is the author’s raw input.
    // 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 covers resolving them.

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; the 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 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.

Was this page helpful?