Render content
Goal: turn the listing from the direct-fetch guide 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 path, this is the advanced layer. The pages editors compose already render through your app: the 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
Section titled “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
processedHTML, and the reasonvalueis never the one to render. - Images resolved through
includedmedia, with correctalt,width, andheight, and a responsivesrcset. - A paginated listing driven by
page[limit],links.next, andmeta.count. - The site’s navigation fetched from its menu endpoint and rendered as a nested nav that every route shares.
Prerequisites
Section titled “Prerequisites”- The shared data module and caching setup from the direct-fetch guide
- Query mechanics (filters,
include, pagination) from the Content API guide
-
Fetch one entry by its ID
Section titled “Fetch one entry by its ID”The detail route below is keyed by the entry’s UUID, which works against any JSON:API backend; alias-keyed URLs are covered at the end of this step with
getResourceByPath().Add a detail fetcher next to
getArticlesin the shared data module. It usesdrupal-jsonapi-paramsto build the query string; install it if you haven’t (npm install drupal-jsonapi-params; the query quickstart already did). The fetcher resolvesincludedentries 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 newserver/utils/acquia.tsand export it, so this fetcher (and later ones) can share it. Nitro auto-importsserver/utils/into server routes.// 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 };}getResourceis generic and returnsT | RawApiResponseWithData<T>, so it needs both the type argument and the cast: without themdocisunknownand every property access below is a compile error under the--strictconfig the quickstart’s scaffold created.Articleis the type the quickstart already exported from this module.Returning
nullfor a miss matters: the detail route turns it into a 404 rather than rendering an empty page.getResourcedoes 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 anerrorsarray, which is why the branch above logs before it returns. AnIMAGE_FIELDthat names a field the entry does not have is the usual rejection, and it is a400:`image` is not a valid relationship field name. Possible values: node_type, revision_uid, uid, field_image.The names after
Possible valuesare the ones your own site uses. SetIMAGE_FIELDto whichever of them holds the image.Alias-keyed routes. The client also ships
getResourceByPath(), which resolves a path against the site’s/router/translate-pathendpoint and then fetches whatever entity that path names. Verified against the shipped package (@drupal-api-client/json-api-client1.4.1): it returnsPromise<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: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
clientyou already built works unchanged: it constructs its own router client from the same credentials, at the site root rather than underapiPrefix. SwapgetArticleByIdfor 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-pathis the contribdecoupled_routermodule’s endpoint, not a Drupal core one. A site without that module serves its HTML 404 page there instead, and the call then throwsUnexpected token '<', "<!DOCTYPE "... is not valid JSONrather than returning the object above. A Canvas Headless app has its own resolver, the SDK’sfetchPage(), which returns the routed page as a component tree (Canvas Headless quickstart). -
Wire the ID into a dynamic route
Section titled “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: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>);}A purely static build would need every ID enumerated in
getStaticPaths, which defeats the point of a UUID-keyed fallback; use an SSR adapter and readAstro.paramsper request instead. An SSR adapter makes Astro render pages on a server at request time instead of once at build time. The quickstart’sminimaltemplate is static, so add one first withnpx astro add node(or your host’s adapter, per Astro’s adapter docs):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>)}The server route does the fetch; the page consumes it and raises a 404 from the miss:
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;});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>The fetcher is server-only and a route loader is isomorphic, so the route reaches it through a server function, the pair First fetch set up.
notFound()thrown in the loader is what turns the miss into a404: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));// src/routes/articles/$id.tsximport { 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>);} -
Render rich text from
Section titled “Render rich text from processed, never value”processed, nevervalueA formatted-text field (like
body) is an object with three properties:valueis the author’s raw input.formatnames the text format the site applies to it.processedis the HTML that results: filtered, with disallowed tags and attributes stripped by the CMS according to that format.
processedis the only one to render. Injectingvaluebypasses the CMS’s sanitization and hands whatever an author (or a compromised author account) typed straight to your visitors’ browsers.bodyis the first field beyondtitleandcreatedthis route reads, so widen theArticletype 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:// 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 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:
<divclassName="article-body"dangerouslySetInnerHTML={{ __html: entry.attributes.body?.processed ?? '' }}/><div class="article-body" set:html={entry.attributes.body?.processed ?? ''} /><div class="article-body" v-html="entry.attributes.body?.processed ?? ''" /><divclassName="article-body"dangerouslySetInnerHTML={{ __html: entry.attributes.body?.processed ?? '' }}/>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.
-
Render images from
Section titled “Render images from included media”includedmediaIMAGE_INCLUDEfrom step 1 embeds the whole chain in one request: the entry’s image relationship points at a media entity, whosemedia_imagerelationship points at the file. (Where the field holds the file directly, one hop is enough:includedcarries thefile--fileitself, andalt,width, andheightride on the entry’s own relationshipmeta. The mapper below then drops its second lookup and reads the meta fromrel.) Two facts from the content model reference make the rendering correct:- The file’s
uri.urlis root-relative. Prefix it withDRUPAL_SITE_URL. - The image’s
alt,width, andheightride on the media entity’smedia_imagerelationshipmeta(the media→file hop), not on the entry’s ownimagerelationshipmeta(verified 2026-07-03: the entry’simagerelationship meta on the test site carries onlytarget_uuid/drupal_internal__target_id, no dimensions).
Resolve the chain through the lookup map from step 1:
// 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:
next/imagegenerates the responsivesrcsetand lazy-loading for you; it needs the CMS host allowlisted once: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 }],},};import Image from 'next/image';{image && <Image src={image.src} alt={image.alt} width={image.width} height={image.height} />}astro:assetsoptimizes remote images at build time (static) or on demand (SSR); allowlist the CMS host inastro.config.mjs: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.envdoes not carry your.envvalues insideastro.config.mjs: the config is evaluated before Astro loads them, soimport.meta.env.DRUPAL_SITE_URLisundefinedthere andnew URL(undefined)fails the build withInvalid URLbefore a single page renders.loadEnvfrom Vite is the supported way to read them at config time.---import { Image } from 'astro:assets';---{image && <Image src={image.src} alt={image.alt} width={image.width} height={image.height} />}With plain markup, the intrinsic dimensions carry the layout; add
@nuxt/imagewhen you want generatedsrcsetvariants:<imgv-if="image":src="image.src":alt="image.alt":width="image.width":height="image.height"loading="lazy"/>There is no image component to reach for, so the intrinsic dimensions carry the layout, as on Nuxt.
toImageis 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.// src/lib/acquia.functions.ts: step 2's server function,// now mapping the image tooimport { 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),};});{image && (<imgsrc={image.src}alt={image.alt}width={image.width}height={image.height}loading="lazy"/>)}For a hand-rolled
srcsetwithout framework tooling, you need more than one image size. The API client’sImage Stylessetting 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 responsivesrcsetfrom the single source image without needing it. - The file’s
-
Page through the listing
Section titled “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.nextexists while there are more entries, andmeta.countis the total. Add a paged fetcher to the data module: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),};}hasNextcomes fromlinks.next, not from arithmetic ontotal. Entries the caller can’t see are filtered after pagination (the omitted-resources behavior), so a page can come back short, or even empty, whilelinks.nextstill 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 andfilter[path.alias]works reliably there, link with the alias instead.The page number lives in the URL (
/articles?page=2), so paged views are linkable, cacheable, and back-button friendly: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></>);}With an SSR adapter (as used for the ID-keyed detail route in step 2), read the page number from
Astro.url.searchParamsper request, the same shape as the Next.js tab: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.nextuntil it disappears) and hand the full list to Astro’spaginate()ingetStaticPaths. 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 onfilter[path.alias](or reading aliases straight from an unfiltered listing) working reliably on your site.The server route takes the page number; the page keeps it in the URL query so navigation and refresh preserve position:
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);});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>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:
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));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></>);}pagestays optional rather than defaulting to1, because a schema that fills it in rewrites/articlesto/articles?page=1on arrival. Normalizing a value that is there is a different thing and worth doing.?page=1.5and?page=-3both canonicalize to?page=1, and?page=abcdrops the parameter, so nothing but a whole page number reaches the offset arithmetic. -
Render the site navigation
Section titled “Render the site navigation”Navigation is content too. A 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 themainmenu.The response is a flat collection, already in menu order, where every item carries
title,url, and aparentthat is empty on top-level items and holds the parent’sidon nested ones (trimmed):{"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:// 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:
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.tsxand put<SiteNav />above{children}. Verified end to end against a site whosemainmenu 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.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.
The server route does the fetch; the component consumes it, and the app’s layout mounts the component:
server/api/nav.get.ts import { getMenu } from '../utils/acquia';export default defineEventHandler(() => getMenu('main'));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>src/lib/acquia.functions.ts import { createServerFn } from '@tanstack/react-start';import { getMenu } from './acquia.server';export const getSiteMenu = createServerFn().handler(() => getMenu('main'),);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()tocreateRootRouteinsrc/routes/__root.tsx, and render<SiteNav nav={Route.useLoaderData()} />above the<Outlet />. Verified end to end against a site whosemainmenu 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.
When something goes wrong
Section titled “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 (<p>…) 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). 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, 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
Section titled “Next steps”- Query content: sharper filters, sparse fieldsets, and relationship traversal for these fetches.
- React to content changes with webhooks: refresh what you just rendered the moment an editor publishes.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)