# Render content

**Goal:** turn the listing from the [direct-fetch guide](/source-cms/content-api/fetch-content/) into a content site: a detail route per entry, rich text and images rendered correctly, and a listing that pages instead of truncating.

On the [Canvas Headless](/start-here/glossary/#canvas-headless) path, this is the advanced layer. The pages editors compose already render through your app: the [quickstart](/source-cms/get-content/quickstart/) registered the app as the site's renderer, and the SDK hands it each composed page as a component tree. Build your own routes for what is not composed in Canvas: a listing that queries the content, a detail page for entries nobody lays out by hand, or a delivery channel with no pages to compose at all.

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

- A detail route that fetches one entry by its UUID, in your framework's dynamic-route idiom.
- Rich text rendered from the field's `processed` HTML, and the reason `value` is never the one to render.
- Images resolved through `included` media, with correct `alt`, `width`, and `height`, and a responsive `srcset`.
- A paginated listing driven by `page[limit]`, `links.next`, and `meta.count`.
- The site's navigation fetched from its menu endpoint and rendered as a nested nav that every route shares.

## Prerequisites

- The shared data module and caching setup from the [direct-fetch guide](/source-cms/content-api/fetch-content/)
- Query mechanics ([filters](/source-cms/reference/query-parameters/#filter-match-entries-by-field-value), `include`, pagination) from the [Content API guide](/source-cms/content-api/guide/)

## Steps

<Steps>

1. ### Fetch one entry by its ID

   The detail route below is keyed by the entry's UUID, which works against any [JSON:API](/start-here/glossary/#jsonapi) backend; alias-keyed URLs are covered at the end of this step with `getResourceByPath()`.

   Add a detail fetcher next to `getArticles` in the shared data module. It uses [`drupal-jsonapi-params`](https://www.npmjs.com/package/drupal-jsonapi-params) to build the query string; install it if you haven't (`npm install drupal-jsonapi-params`; the [query quickstart](/source-cms/content-api/quickstart/) already did). The fetcher resolves `included` entries into a lookup map once, at the data boundary, so components never dig through the raw document:

   Nuxt readers: the quickstart built the client inside `server/api/articles.get.ts`; move the client construction into a new `server/utils/acquia.ts` and export it, so this fetcher (and later ones) can share it. Nitro auto-imports `server/utils/` into server routes.

   ```ts
   // lib/acquia.ts (Next.js) / src/lib/acquia.ts (Astro) / server/utils/acquia.ts (Nuxt)
   // src/lib/acquia.server.ts (TanStack Start)
   import { DrupalJsonApiParams } from 'drupal-jsonapi-params';

   // Your site's image field, read off `relationships` on any entry in the
   // listing. Fields made with Source CMS's tooling have unprefixed names and
   // reference a media entity, so the include takes a second hop to the file.
   // A `field_image` is the classic Drupal image field and holds the file
   // directly: then IMAGE_INCLUDE is IMAGE_FIELD on its own.
   const IMAGE_FIELD = 'image';
   const IMAGE_INCLUDE = `${IMAGE_FIELD}.media_image`;

   type ArticleDocument = {
     data?: Article;
     included?: any[];
     errors?: { status: string; title: string; detail?: string }[];
   };

   export async function getArticleById(id: string) {
     const queryString = new DrupalJsonApiParams()
       .addInclude([IMAGE_INCLUDE])
       .getQueryString();

     const doc = (await client.getResource<ArticleDocument>('node--article', id, {
       queryString,
     })) as ArticleDocument;
     if (doc.errors?.length) {
       // A rejected request arrives here too, not only a miss. Log it, or the
       // route answers 404 for a 400 nobody ever saw.
       console.error('node--article', id, doc.errors);
       return null;
     }
     if (!doc.data) return null;

     const entry = doc.data;
     const included = new Map<string, any>(
       (doc.included ?? []).map((r: any) => [`${r.type}:${r.id}`, r]),
     );
     return { entry, included };
   }
   ```

   `getResource` is generic and returns `T | RawApiResponseWithData<T>`, so it needs both the type argument and the cast: without them `doc` is `unknown` and every property access below is a compile error under the `--strict` config the quickstart's scaffold created. `Article` is the type the quickstart already exported from this module.

   Returning `null` for a miss matters: the detail route turns it into a 404 rather than rendering an empty page. `getResource` does not throw on an HTTP error status, though, and a miss is not the only thing that produces one. A rejected request comes back as the same kind of document with an `errors` array, which is why the branch above logs before it returns. An `IMAGE_FIELD` that names a field the entry does not have is the usual rejection, and it is a `400`:

   ```text
   `image` is not a valid relationship field name. Possible values: node_type, revision_uid, uid, field_image.
   ```

   The names after `Possible values` are the ones your own site uses. Set `IMAGE_FIELD` to whichever of them holds the image.

   **Alias-keyed routes.** The client also ships `getResourceByPath()`, which resolves a path against the site's `/router/translate-path` endpoint and then fetches whatever entity that path names. Verified against the shipped package (`@drupal-api-client/json-api-client` 1.4.1): it returns `Promise<unknown>`, and an **unresolved path comes back as a `{ message, details }` object rather than throwing**, so guard on the shape, never with a try/catch:

   ```ts
   // lib/acquia.ts
   type UnresolvedPath = { message: string; details: string };

   export async function getArticleByPath(path: string) {
     const queryString = new DrupalJsonApiParams()
       .addInclude([IMAGE_INCLUDE])
       .getQueryString();

     const doc = (await client.getResourceByPath(path, { queryString })) as
       | ArticleDocument
       | UnresolvedPath;
     if (!('data' in doc) || !doc.data) return null;

     const entry = doc.data;
     const included = new Map<string, any>(
       (doc.included ?? []).map((r: any) => [`${r.type}:${r.id}`, r]),
     );
     return { entry, included };
   }
   ```

   The `client` you already built works unchanged: it constructs its own router client from the same credentials, at the site root rather than under `apiPrefix`. Swap `getArticleById` for this and the routes in step 2 become catch-all segments (`[...slug]`) whose parts you rejoin into a leading-slash path.

   Confirm the router answers on your own site first, the same check the alias filter needs. `/router/translate-path` is the contrib `decoupled_router` module's endpoint, not a Drupal core one. A site without that module serves its HTML 404 page there instead, and the call then throws `Unexpected token '<', "<!DOCTYPE "... is not valid JSON` rather than returning the object above. A [Canvas Headless](/start-here/glossary/#canvas-headless) app has its own resolver, the SDK's `fetchPage()`, which returns the routed page as a component tree ([Canvas Headless quickstart](/source-cms/get-content/quickstart/)).

2. ### Wire the ID into a dynamic route

   Each framework has one idiom for "the rest of the URL is the key". The route derives the ID from its params, calls the fetcher, and 404s on `null`:

   <Tabs syncKey="framework">

   <TabItem label="Next.js">

   ```tsx
   // app/articles/[id]/page.tsx
   import { notFound } from 'next/navigation';
   import { getArticleById } from '../../../lib/acquia';

   export default async function ArticlePage({
     params,
   }: {
     params: Promise<{ id: string }>;
   }) {
     const { id } = await params;
     const result = await getArticleById(id);
     if (!result) notFound();

     const { entry } = result;
     return (
       <article>
         <h1>{entry.attributes.title}</h1>
       </article>
     );
   }
   ```

   </TabItem>

   <TabItem label="Astro">

   A purely static build would need every ID enumerated in `getStaticPaths`, which defeats the point of a UUID-keyed fallback; use an [SSR](/start-here/glossary/#ssr) adapter and read `Astro.params` per request instead. An SSR adapter makes Astro render pages on a server at request time instead of once at build time. The quickstart's `minimal` template is static, so add one first with `npx astro add node` (or your host's adapter, per [Astro's adapter docs](https://docs.astro.build/en/guides/on-demand-rendering/)):

   ```astro
   ---
   // src/pages/articles/[id].astro
   import { getArticleById } from '../../lib/acquia';

   const result = await getArticleById(Astro.params.id!);
   if (!result) Astro.response.status = 404;
   const entry = result?.entry;
   ---

   {!entry && <p>Not found</p>}
   {entry && (
     <article>
       <h1>{entry.attributes.title}</h1>
     </article>
   )}
   ```

   </TabItem>

   <TabItem label="Nuxt">

   The server route does the fetch; the page consumes it and raises a 404 from the miss:

   ```ts
   // server/api/articles/[id].get.ts
   import { getArticleById } from '../../utils/acquia';

   export default defineEventHandler(async (event) => {
     const id = getRouterParam(event, 'id');
     const result = await getArticleById(id!);
     if (!result) {
       throw createError({ statusCode: 404, statusMessage: 'Not found' });
     }
     return result;
   });
   ```

   ```vue
   <!-- app/pages/articles/[id].vue -->
   <script setup lang="ts">
   const route = useRoute();
   const { data, error } = await useFetch(`/api/articles/${route.params.id}`);
   if (error.value) {
     throw createError({ statusCode: 404, statusMessage: 'Not found', fatal: true });
   }
   const entry = computed(() => data.value!.entry);
   </script>

   <template>
     <article>
       <h1>{{ entry.attributes.title }}</h1>
     </article>
   </template>
   ```

   </TabItem>

   <TabItem label="TanStack Start">

   The fetcher is server-only and a route loader is isomorphic, so the route reaches it through a server function, the pair [First fetch](/source-cms/content-api/first-fetch/) set up. `notFound()` thrown in the loader is what turns the miss into a `404`:

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

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

   export const getArticle = createServerFn()
     .validator((id: string) => id)
     .handler(({ data }) => getArticleById(data));
   ```

   ```tsx
   // src/routes/articles/$id.tsx
   import { createFileRoute, notFound } from '@tanstack/react-router';
   import { getArticle } from '../../lib/acquia.functions';

   export const Route = createFileRoute('/articles/$id')({
     loader: async ({ params }) => {
       const result = await getArticle({ data: params.id });
       if (!result) throw notFound();
       return result;
     },
     component: ArticlePage,
   });

   function ArticlePage() {
     const { entry } = Route.useLoaderData();
     return (
       <article>
         <h1>{entry.attributes.title}</h1>
       </article>
     );
   }
   ```

   </TabItem>

   </Tabs>

3. ### Render rich text from `processed`, never `value`

   A formatted-text [field](/start-here/glossary/#field) (like `body`) is an object with three properties:

   - `value` is the author's raw input.
   - `format` names the text format the site applies to it.
   - `processed` is the HTML that results: filtered, with disallowed tags and attributes stripped by the CMS according to that format.

   `processed` is the only one to render. Injecting `value` bypasses the CMS's sanitization and hands whatever an author (or a compromised author account) typed straight to your visitors' browsers.

   `body` is the first field beyond `title` and `created` this route reads, so widen the `Article` type before you render it. The quickstart's data module declares the two fields it renders and no more, and TypeScript rejects anything else read off it: `Property 'body' does not exist on type '{ title: string; created: string; }'`. Add each field you map, starting with this one:

   ```ts
   // lib/acquia.ts (Next.js) / src/lib/acquia.ts (Astro) / server/utils/acquia.ts (Nuxt)
   // src/lib/acquia.server.ts (TanStack Start)
   export type Article = {
     id: string;
     attributes: {
       title: string;
       created: string;
       body?: { value: string; processed: string } | null;
     };
   };
   ```

   Optional, because an entry whose author left the field empty comes back without it. That optionality is what the `?.` in every sample below is for. (The [direct-fetch guide](/source-cms/content-api/fetch-content/) makes the same edit in its mapping step; if you came through it, your module already has this.)

   Each framework has an explicit "I am injecting HTML" marker. That explicitness is the point: it should only ever appear at this one boundary, fed only by CMS-processed output:

   <Tabs syncKey="framework">

   <TabItem label="Next.js">

   ```tsx
   <div
     className="article-body"
     dangerouslySetInnerHTML={{ __html: entry.attributes.body?.processed ?? '' }}
   />
   ```

   </TabItem>

   <TabItem label="Astro">

   ```astro
   <div class="article-body" set:html={entry.attributes.body?.processed ?? ''} />
   ```

   </TabItem>

   <TabItem label="Nuxt">

   ```vue
   <div class="article-body" v-html="entry.attributes.body?.processed ?? ''" />
   ```

   </TabItem>

   <TabItem label="TanStack Start">

   ```tsx
   <div
     className="article-body"
     dangerouslySetInnerHTML={{ __html: entry.attributes.body?.processed ?? '' }}
   />
   ```

   </TabItem>

   </Tabs>

   The trust boundary is the text format, configured in the CMS. If your site allows untrusted users to author with a permissive format, that is a CMS configuration problem no frontend sanitizer fully repairs; fix the format.

4. ### Render images from `included` media

   `IMAGE_INCLUDE` from step 1 embeds the whole chain in one request: the entry's image relationship points at a media entity, whose `media_image` relationship points at the file. (Where the field holds the file directly, one hop is enough: `included` carries the `file--file` itself, and `alt`, `width`, and `height` ride on the entry's own relationship `meta`. The mapper below then drops its second lookup and reads the meta from `rel`.) Two facts from the [content model reference](/source-cms/reference/content-model/) make the rendering correct:

   - The file's `uri.url` is root-relative. Prefix it with `DRUPAL_SITE_URL`.
   - The image's `alt`, `width`, and `height` ride on the **media entity's `media_image` relationship `meta`** (the media→file hop), not on the entry's own `image` relationship `meta` (verified 2026-07-03: the entry's `image` relationship meta on the test site carries only `target_uuid`/`drupal_internal__target_id`, no dimensions).

   Resolve the chain through the lookup map from step 1:

   ```ts
   // lib/acquia.ts: resolve an image relationship to renderable props
   // On Astro, read this from import.meta.env instead.
   const siteUrl = process.env.DRUPAL_SITE_URL;

   export function toImage(
     entry: any,
     included: Map<string, any>,
   ): { src: string; alt: string; width: number; height: number } | null {
     const rel = entry.relationships[IMAGE_FIELD]?.data;
     if (!rel) return null;

     const media = included.get(`${rel.type}:${rel.id}`);
     const fileRel = media?.relationships.media_image?.data;
     const file = fileRel && included.get(`${fileRel.type}:${fileRel.id}`);
     if (!file) return null;

     return {
       src: `${siteUrl}${file.attributes.uri.url}`,
       alt: fileRel.meta?.alt ?? '',
       width: fileRel.meta?.width,
       height: fileRel.meta?.height,
     };
   }
   ```

   Render it with the intrinsic dimensions so the browser reserves space before the image loads (no layout shift), and let the framework's image tooling do the rest:

   <Tabs syncKey="framework">

   <TabItem label="Next.js">

   `next/image` generates the responsive `srcset` and lazy-loading for you; it needs the CMS host allowlisted once:

   ```js
   // next.config.js
   module.exports = {
     // Unrelated to images: disables the in-memory ISR cache, which
     // Front End Hosting requires (see the deploy guide). Drop it if
     // you deploy elsewhere.
     cacheMaxMemorySize: 0,
     images: {
       remotePatterns: [{ protocol: 'https', hostname: new URL(process.env.DRUPAL_SITE_URL).hostname }],
     },
   };
   ```

   ```tsx
   import Image from 'next/image';

   {image && <Image src={image.src} alt={image.alt} width={image.width} height={image.height} />}
   ```

   </TabItem>

   <TabItem label="Astro">

   `astro:assets` optimizes remote images at build time (static) or on demand (SSR); allowlist the CMS host in `astro.config.mjs`:

   ```js
   // astro.config.mjs
   import { defineConfig } from 'astro/config';
   import { loadEnv } from 'vite';

   const { DRUPAL_SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '');

   export default defineConfig({
     image: {
       domains: [new URL(DRUPAL_SITE_URL).hostname],
     },
   });
   ```

   `import.meta.env` does not carry your `.env` values inside `astro.config.mjs`: the config is evaluated before Astro loads them, so `import.meta.env.DRUPAL_SITE_URL` is `undefined` there and `new URL(undefined)` fails the build with `Invalid URL` before a single page renders. `loadEnv` from Vite is the supported way to read them at config time.

   ```astro
   ---
   import { Image } from 'astro:assets';
   ---

   {image && <Image src={image.src} alt={image.alt} width={image.width} height={image.height} />}
   ```

   </TabItem>

   <TabItem label="Nuxt">

   With plain markup, the intrinsic dimensions carry the layout; add `@nuxt/image` when you want generated `srcset` variants:

   ```vue
   <img
     v-if="image"
     :src="image.src"
     :alt="image.alt"
     :width="image.width"
     :height="image.height"
     loading="lazy"
   />
   ```

   </TabItem>

   <TabItem label="TanStack Start">

   There is no image component to reach for, so the intrinsic dimensions carry the layout, as on Nuxt. `toImage` is server-only, so it runs where the fetch does: widen step 2's server function to hand the route renderable props instead of the lookup map.

   ```ts
   // src/lib/acquia.functions.ts: step 2's server function,
   // now mapping the image too
   import { createServerFn } from '@tanstack/react-start';

   import { getArticleById, toImage } from './acquia.server';

   export const getArticle = createServerFn()
     .validator((id: string) => id)
     .handler(async ({ data }) => {
       const result = await getArticleById(data);
       if (!result) return null;
       return {
         entry: result.entry,
         image: toImage(result.entry, result.included),
       };
     });
   ```

   ```tsx
   {image && (
     <img
       src={image.src}
       alt={image.alt}
       width={image.width}
       height={image.height}
       loading="lazy"
     />
   )}
   ```

   </TabItem>

   </Tabs>

   For a hand-rolled `srcset` without framework tooling, you need more than one image size. The [API client](/start-here/glossary/#api-client)'s `Image Styles` setting controls whether responses expose the site's image derivatives for exactly this purpose, but enabling it is an admin action on the API client's configuration, not a frontend code change. The framework `<Image>` components above already generate a responsive `srcset` from the single source image without needing it.

5. ### Page through the listing

   One page of results is capped at 50 entries (`page[limit]` clamps there), so any real listing pages. The response gives you everything a pager needs: `links.next` exists while there are more entries, and `meta.count` is the total. Add a paged fetcher to the data module:

   ```ts
   // lib/acquia.ts
   const PAGE_SIZE = 10;

   export async function getArticlePage(page: number) {
     const queryString = new DrupalJsonApiParams()
       .addFields('node--article', ['title', 'created', 'path'])
       .addPageLimit(PAGE_SIZE)
       .addPageOffset((page - 1) * PAGE_SIZE)
       .addSort('created', 'DESC')
       .getQueryString();

     type ArticlePage = {
       data: Article[];
       meta?: { count?: number };
       links?: { next?: { href: string } };
     };

     const doc = (await client.getCollection<ArticlePage>('node--article', {
       queryString,
     })) as ArticlePage;
     return {
       articles: doc.data,
       total: doc.meta?.count ?? doc.data.length,
       hasNext: Boolean(doc.links?.next),
     };
   }
   ```

   `hasNext` comes from `links.next`, not from arithmetic on `total`. Entries the caller can't see are filtered *after* pagination (the [omitted-resources behavior](/source-cms/reference/content-api/#omitted-resources)), so a page can come back short, or even empty, while `links.next` still points onward. Trust the link.

   The listing links below use each entry's ID rather than `path.alias`, matching the ID-keyed detail route from step 1 (and matching a fresh site, where no alias has been set on anything yet). If your site's aliases are populated and `filter[path.alias]` works reliably there, link with the alias instead.

   <Tabs syncKey="framework">

   <TabItem label="Next.js">

   The page number lives in the URL (`/articles?page=2`), so paged views are linkable, cacheable, and back-button friendly:

   ```tsx
   // app/articles/page.tsx
   import Link from 'next/link';
   import { getArticlePage } from '../../lib/acquia';

   export default async function ArticlesPage({
     searchParams,
   }: {
     searchParams: Promise<{ page?: string }>;
   }) {
     const page = Math.max(1, Number((await searchParams).page) || 1);
     const { articles, hasNext } = await getArticlePage(page);

     return (
       <>
         <ul>
           {articles.map((a) => (
             <li key={a.id}>
               <Link href={`/articles/${a.id}`}>{a.attributes.title}</Link>
             </li>
           ))}
         </ul>
         <nav>
           {page > 1 && <Link href={`/articles?page=${page - 1}`}>Newer</Link>}
           {hasNext && <Link href={`/articles?page=${page + 1}`}>Older</Link>}
         </nav>
       </>
     );
   }
   ```

   </TabItem>

   <TabItem label="Astro">

   With an SSR adapter (as used for the ID-keyed detail route in step 2), read the page number from `Astro.url.searchParams` per request, the same shape as the Next.js tab:

   ```astro
   ---
   // src/pages/articles/index.astro
   import { getArticlePage } from '../../lib/acquia';

   const page = Math.max(1, Number(Astro.url.searchParams.get('page')) || 1);
   const { articles, hasNext } = await getArticlePage(page);
   ---

   <ul>
     {articles.map((a) => <li><a href={`/articles/${a.id}`}>{a.attributes.title}</a></li>)}
   </ul>
   <nav>
     {page > 1 && <a href={`/articles?page=${page - 1}`}>Newer</a>}
     {hasNext && <a href={`/articles?page=${page + 1}`}>Older</a>}
   </nav>
   ```

   With static output, every page must be prebuilt: walk the collection once at build time (follow `links.next` until it disappears) and hand the full list to Astro's `paginate()` in `getStaticPaths`. Note that a static build can't serve the ID-keyed detail route from step 2 either; that route is SSR-only on the verified path. Static output needs alias-keyed routes enumerable at build time, which depends on `filter[path.alias]` (or reading aliases straight from an unfiltered listing) working reliably on your site.

   </TabItem>

   <TabItem label="Nuxt">

   The server route takes the page number; the page keeps it in the URL query so navigation and refresh preserve position:

   ```ts
   // server/api/articles/index.get.ts
   import { getArticlePage } from '../../utils/acquia';

   export default defineEventHandler((event) => {
     const page = Math.max(1, Number(getQuery(event).page) || 1);
     return getArticlePage(page);
   });
   ```

   ```vue
   <!-- app/pages/articles/index.vue -->
   <script setup lang="ts">
   const route = useRoute();
   const page = computed(() => Math.max(1, Number(route.query.page) || 1));
   const { data } = await useFetch('/api/articles', { query: { page } });
   </script>

   <template>
     <ul>
       <li v-for="a in data.articles" :key="a.id">
         <NuxtLink :to="`/articles/${a.id}`">{{ a.attributes.title }}</NuxtLink>
       </li>
     </ul>
     <nav>
       <NuxtLink v-if="page > 1" :to="{ query: { page: page - 1 } }">Newer</NuxtLink>
       <NuxtLink v-if="data.hasNext" :to="{ query: { page: page + 1 } }">Older</NuxtLink>
     </nav>
   </template>
   ```

   </TabItem>

   <TabItem label="TanStack Start">

   The page number is a search param the route validates, so it is part of the URL and part of the loader's cache key:

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

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

   export const listArticlePage = createServerFn()
     .validator((page: number) => page)
     .handler(({ data }) => getArticlePage(data));
   ```

   ```tsx
   // src/routes/articles/index.tsx
   import { Link, createFileRoute } from '@tanstack/react-router';
   import { listArticlePage } from '../../lib/acquia.functions';

   import type { Article } from '../../lib/acquia.server';

   export const Route = createFileRoute('/articles/')({
     validateSearch: (search: Record<string, unknown>) => ({
       page: Math.max(1, Math.trunc(Number(search.page))) || undefined,
     }),
     loaderDeps: ({ search }) => ({ page: search.page ?? 1 }),
     loader: ({ deps }) => listArticlePage({ data: deps.page }),
     component: ArticlesPage,
   });

   function ArticlesPage() {
     const page = Route.useSearch().page ?? 1;
     const { articles, hasNext } = Route.useLoaderData();
     return (
       <>
         <ul>
           {articles.map((a: Article) => (
             <li key={a.id}>
               <Link to="/articles/$id" params={{ id: a.id }}>
                 {a.attributes.title}
               </Link>
             </li>
           ))}
         </ul>
         <nav>
           {page > 1 && (
             <Link to="/articles" search={{ page: page - 1 }}>Newer</Link>
           )}
           {hasNext && (
             <Link to="/articles" search={{ page: page + 1 }}>Older</Link>
           )}
         </nav>
       </>
     );
   }
   ```

   `page` stays optional rather than defaulting to `1`, because a schema that fills it in rewrites `/articles` to `/articles?page=1` on arrival. Normalizing a value that is there is a different thing and worth doing. `?page=1.5` and `?page=-3` both canonicalize to `?page=1`, and `?page=abc` drops the parameter, so nothing but a whole page number reaches the offset arithmetic.

   </TabItem>

   </Tabs>

6. ### Render the site navigation

   Navigation is content too. A [menu](/start-here/glossary/#menu) is a named, ordered tree of links that editors manage in the CMS, and a frontend that renders it from data picks up a reordered or added link without a deploy. Each menu is served at `/api/menu_items/{menu}`; a site's primary navigation is the `main` menu.

   The response is a flat collection, already in menu order, where every item carries `title`, `url`, and a `parent` that is empty on top-level items and holds the parent's `id` on nested ones (trimmed):

   ```json
   {
     "data": [
       {
         "type": "menu_link_content--menu_link_content",
         "id": "standard.front_page",
         "attributes": { "title": "Home", "url": "/", "parent": "" }
       },
       {
         "type": "menu_link_content--menu_link_content",
         "id": "menu_link_content:7bdd2c85-8bfe-44fe-a9c6-0d4682f394e2",
         "attributes": { "title": "About", "url": "/about", "parent": "" }
       },
       {
         "type": "menu_link_content--menu_link_content",
         "id": "menu_link_content:db118623-8653-49e7-b800-275f49fa2f79",
         "attributes": {
           "title": "Team",
           "url": "/about/team",
           "parent": "menu_link_content:7bdd2c85-8bfe-44fe-a9c6-0d4682f394e2"
         }
       }
     ]
   }
   ```

   Add the fetcher to the shared data module. The client reaches the endpoint as `menu_items--{menu}` (it splits the type on `--` into the two path segments), and one pass over the flat list nests children under parents:

   ```ts
   // lib/acquia.ts (Next.js) / src/lib/acquia.ts (Astro) / server/utils/acquia.ts (Nuxt)
   // src/lib/acquia.server.ts (TanStack Start)
   type MenuItem = {
     id: string;
     attributes: { title: string; url: string; parent: string };
   };

   type MenuDocument = {
     data: MenuItem[];
     errors?: { status: string; title: string; detail?: string }[];
   };

   export type NavItem = {
     id: string;
     title: string;
     url: string;
     children: NavItem[];
   };

   export async function getMenu(menu: string): Promise<NavItem[]> {
     const doc = (await client.getCollection<MenuDocument>(
       `menu_items--${menu}`,
     )) as MenuDocument;
     if (doc.errors?.length) {
       // A menu name with no match arrives here as a 404 document, not a throw.
       console.error(`menu_items--${menu}`, doc.errors);
       return [];
     }

     const byId = new Map<string, NavItem>();
     for (const { id, attributes } of doc.data) {
       byId.set(id, {
         id,
         title: attributes.title,
         url: attributes.url,
         children: [],
       });
     }
     const roots: NavItem[] = [];
     for (const { id, attributes } of doc.data) {
       const parent = byId.get(attributes.parent);
       (parent ? parent.children : roots).push(byId.get(id)!);
     }
     return roots;
   }
   ```

   Render the tree in a component your layout mounts once, so every route has the navigation:

   <Tabs syncKey="framework">

   <TabItem label="Next.js">

   ```tsx
   // app/components/site-nav.tsx
   import Link from 'next/link';
   import { getMenu } from '../../lib/acquia';

   export default async function SiteNav() {
     const nav = await getMenu('main');
     return (
       <nav>
         <ul>
           {nav.map((item) => (
             <li key={item.id}>
               <Link href={item.url}>{item.title}</Link>
               {item.children.length > 0 && (
                 <ul>
                   {item.children.map((child) => (
                     <li key={child.id}>
                       <Link href={child.url}>{child.title}</Link>
                     </li>
                   ))}
                 </ul>
               )}
             </li>
           ))}
         </ul>
       </nav>
     );
   }
   ```

   A Server Component may be async, so the layout renders it directly: import it in `app/layout.tsx` and put `<SiteNav />` above `{children}`. Verified end to end against a site whose `main` menu holds Home, Articles, and About with Team nested under it: the layout renders the four links as a `<nav>` with Team's `<ul>` nested inside About's list item.

   </TabItem>

   <TabItem label="Astro">

   ```astro
   ---
   // src/components/SiteNav.astro
   import { getMenu } from '../lib/acquia';

   const nav = await getMenu('main');
   ---

   <nav>
     <ul>
       {nav.map((item) => (
         <li>
           <a href={item.url}>{item.title}</a>
           {item.children.length > 0 && (
             <ul>
               {item.children.map((child) => (
                 <li><a href={child.url}>{child.title}</a></li>
               ))}
             </ul>
           )}
         </li>
       ))}
     </ul>
   </nav>
   ```

   Mount it once in the layout component your pages share.

   </TabItem>

   <TabItem label="Nuxt">

   The server route does the fetch; the component consumes it, and the app's layout mounts the component:

   ```ts
   // server/api/nav.get.ts
   import { getMenu } from '../utils/acquia';

   export default defineEventHandler(() => getMenu('main'));
   ```

   ```vue
   <!-- app/components/SiteNav.vue -->
   <script setup lang="ts">
   import type { NavItem } from '../../server/utils/acquia';

   const { data: nav } = await useFetch<NavItem[]>('/api/nav');
   </script>

   <template>
     <nav>
       <ul>
         <li v-for="item in nav ?? []" :key="item.id">
           <NuxtLink :to="item.url">{{ item.title }}</NuxtLink>
           <ul v-if="item.children.length">
             <li v-for="child in item.children" :key="child.id">
               <NuxtLink :to="child.url">{{ child.title }}</NuxtLink>
             </li>
           </ul>
         </li>
       </ul>
     </nav>
   </template>
   ```

   </TabItem>

   <TabItem label="TanStack Start">

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

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

   export const getSiteMenu = createServerFn().handler(
     () => getMenu('main'),
   );
   ```

   ```tsx
   // src/components/SiteNav.tsx
   import { Link } from '@tanstack/react-router';

   import type { NavItem } from '../lib/acquia.server';

   export default function SiteNav({ nav }: { nav: NavItem[] }) {
     return (
       <nav>
         <ul>
           {nav.map((item) => (
             <li key={item.id}>
               <Link to={item.url}>{item.title}</Link>
               {item.children.length > 0 && (
                 <ul>
                   {item.children.map((child) => (
                     <li key={child.id}>
                       <Link to={child.url}>{child.title}</Link>
                     </li>
                   ))}
                 </ul>
               )}
             </li>
           ))}
         </ul>
       </nav>
     );
   }
   ```

   Every route needs the menu, so it belongs on the root route: add `loader: () => getSiteMenu()` to `createRootRoute` in `src/routes/__root.tsx`, and render `<SiteNav nav={Route.useLoaderData()} />` above the `<Outlet />`. Verified end to end against a site whose `main` menu nests two links under Articles and one under Executive communication: every route renders the tree as a `<nav>` with each parent's children in a `<ul>` inside its list item.

   </TabItem>

   </Tabs>

   :::note[Where you control the codebase, add the module that serves the endpoint]
   `/api/menu_items/{menu}` is the contrib `jsonapi_menu_items` module's route, not a Drupal core one, so a site without the module serves Drupal's HTML 404 page there and `getMenu` throws `Unexpected token '<', "<!DOCTYPE "... is not valid JSON` (verified). Two commands add the route (a [DDEV](/start-here/glossary/#ddev)-managed project shown; elsewhere, drop the `ddev` prefix):

   ```bash
   ddev composer require drupal/jsonapi_menu_items
   ddev drush en jsonapi_menu_items -y
   ```

   The fetcher then works unchanged (verified against `jsonapi_menu_items` 1.2.8). A freshly installed Drupal site's `main` menu holds one Home link; add the rest at `/admin/structure/menu/manage/main`.

   Drupal core also ships a menu endpoint of its own, needing no module: `GET /system/menu/{menu}/linkset` answers `application/linkset+json` (RFC 9264), each link carrying `href`, `title`, and a positional `hierarchy` array. It is off by default; one command enables it:

   ```bash
   ddev drush config:set system.feature_flags linkset_endpoint true -y
   ```

   Its document is not JSON:API's shape, so the fetcher above does not read it; it is the fallback when adding a module is not an option.
   :::

</Steps>

## When something goes wrong

**The detail route 404s for an entry the listing shows**: two causes, and the log line in step 1's fetcher tells them apart.

The first is a rejected request that the fetcher turned into `null`. `getResource` reports an HTTP error as an `errors` array on an otherwise ordinary document, so a `400` and a genuine miss reach the route as the same `null`. The usual `400` is `IMAGE_FIELD` naming a relationship the entry does not have: `` `image` is not a valid relationship field name. Possible values: node_type, revision_uid, uid, field_image. `` Correct the field name and the same request answers `200`. A fetcher that returns `null` without logging shows you none of this.

The second is a real miss: the ID reaching the route doesn't match the entry's actual UUID (a stale link, or the listing's `id` field was mapped incorrectly). Log the ID the route received and compare it against the entry's `id` in the listing response; they must match exactly. If you've switched to the alias-keyed variant, the same check applies to `path.alias` instead, including the leading slash.

**Rich text renders as escaped tags (`&lt;p&gt;…`) instead of formatting**: the HTML went through the framework's default text interpolation instead of the HTML-injection marker from step 3. Interpolation escaping is correct everywhere else; only the `processed` boundary uses `dangerouslySetInnerHTML`/`set:html`/`v-html`.

**Images 404 or render broken**: the `src` is missing its host. `uri.url` is root-relative; prefix it with `DRUPAL_SITE_URL` in the mapper, never in components. If the URL is right and Next.js throws `Invalid src prop`, the CMS hostname isn't in `images.remotePatterns`.

**The page layout jumps as images load**: `width` and `height` aren't reaching the `<img>`. Check the relationship `meta` in the raw response; if an image was uploaded without dimensions, fall back to a fixed aspect-ratio container in CSS.

**A middle page of the listing is empty but next/previous links still work**: access filtering ran after pagination and omitted every entry on that page ([omitted resources](/source-cms/reference/content-api/#omitted-resources)). The pager is behaving correctly; render the empty state and keep the navigation links.

**The nav renders empty, and `getMenu` logged a `404` whose detail reads `The "menu" parameter was not converted for the path "/api/menu_items/{menu}" (route name: "jsonapi_menu_items.menu")`**: the endpoint is live but no menu has that machine name (verified literal). Menus are addressed by [machine name](/start-here/glossary/#machine-name), not label; read the name from the menu's admin URL or from a menu response's `attributes.menu_name`.

**`SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON` thrown by `getMenu`**: the site has no `/api/menu_items` route at all, so the client got Drupal's HTML 404 page (verified on a stock Drupal 11 site). The note in step 6 has the two commands that add the route.

## Next steps

- [Query content](/source-cms/content-api/guide/): sharper filters, sparse fieldsets, and relationship traversal for these fetches.
- [React to content changes with webhooks](/source-cms/content-api/webhooks/): refresh what you just rendered the moment an editor publishes.
