Skip to 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.

  • 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.
  1. 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 getArticles in the shared data module. It uses drupal-jsonapi-params to build the query string; install it if you haven’t (npm install drupal-jsonapi-params; the query 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.

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

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

    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 app has its own resolver, the SDK’s fetchPage(), which returns the routed page as a component tree (Canvas Headless quickstart).

  2. 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>
    );
    }
  3. Render rich text from processed, never value

    Section titled “Render rich text from processed, never value”

    A formatted-text 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:

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

    <div
    className="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.

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

    // 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/image generates the responsive srcset and 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} />}

    For a hand-rolled srcset without framework tooling, you need more than one image size. The 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. 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:

    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), 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.

    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>
    </>
    );
    }
  6. 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 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):

    {
    "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.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.

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

Was this page helpful?