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.
What you’ll have when you’re done
Section titled “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
Section titled “Prerequisites”- The working app from First fetch
- Credentials and token basics from the auth quickstart
-
Know the request you’re making
Section titled “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/articleAccept: application/vnd.api+jsonAuthorization: Bearer <access token>The path is
/api/<entity type>/<bundle>:nodeis the entity type,articlethe content type. The token comes from theclient_credentialsgrant; 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
attributeswith its machine name verbatim. - References to other entities (media, taxonomy) live under
relationships, and are fetched alongside the entry with theincludequery parameter (as in?include=image). links.nextappears 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_SECRETnever ships to the client: - Every field appears under
-
Put the fetch where it belongs
Section titled “Put the fetch where it belongs”One rule is absolute:
DRUPAL_CLIENT_SECRETmust 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.Fetch in component frontmatter, exactly as First fetch did:
---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 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.Fetch in a Nitro server route and consume it with
useFetch, exactly as First fetch did:<script setup lang="ts">const { data: articles } = await useFetch('/api/articles');</script>useFetchruns 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:
useAsyncDatawith a direct$fetchto 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 insideserver/, whereprocess.envand the secret are safe, and let pages talk only to your own/api/*routes.Fetch in a server function, called from the route’s loader, exactly as First fetch did:
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.tssuffix 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_credentialsgrant and all, into the browser bundle. -
Fetch from a Canvas code component
Section titled “Fetch from a Canvas code component”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(theswrpackage’s default export),DrupalJsonApiParamsfromdrupal-jsonapi-params, andJsonApiClientfrom thedrupal-canvaspackage. That client is the Drupal API Client, preconfigured with the site’s URL and API prefix (/apihere, and noapiPrefixoption to get wrong). The queries from the querying guide work unchanged, and while you build, eachuseSWRfetch’s result shows in theData Fetchpane 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 Fetchpane 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.
-
Cache what you fetch, and revalidate it
Section titled “Cache what you fetch, and revalidate it”Two things are worth caching. The first, the token (it lives
expires_inseconds, 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,
fetchresults are not cached by default, and the caching options ride on thefetchcall itself. The client’scustomFetchoption 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:articlehere), since those are the names it sends when content changes; a tag you invent has nothing to invalidate it. Add it to First fetch’slib/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.
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 (see the webhooks guide). 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.
Cache at the server-route boundary with Nitro route rules in
nuxt.config.ts. Pages keep calling/api/articlesand Nitro serves the cached result: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 works here too (see the webhooks guide).
TanStack Start has no server-side data cache of its own. A route’s
staleTimecaches 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’ssrc/lib/acquia.server.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
getCachedArticlesand 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 receiver callingclearContentCache()closes the window on publish. -
Map responses to components
Section titled “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:
titleis required on every entry, but most other fields are optional and come backnull(or absent, under sparse fieldsets) when an author left them empty.- Rich-text fields are objects whose
processedproperty holds the HTML rendered through the site’s text format. Use it, notvalue, 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 (
imageand friends) arrive separately in the document’sincludedarray when you request them with?include=; the query parameters reference covers resolving them.
When something goes wrong
Section titled “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; 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.
Next steps
Section titled “Next steps”- Render content: detail routes, rich text, images, and a paginated listing built on this pattern.
- Query by type, field, and relationship: filters, sorting, and pagination on top of the fetch you now understand.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)