Navigation, links and sitemaps
Three questions come up on every site: where the current page sits in the tree, where another page lives so you can link to it, and which pages a search engine should know about. Each has its own endpoint and SDK helper.
Menus, breadcrumbs and pagers
Section titled “Menus, breadcrumbs and pagers”GET /nodes reads the published page tree. You name an anchor and
what you want relative to it, and each common need is one request:
| Need | Request |
|---|---|
| Whole site tree | GET /nodes |
| Site header menu | GET /nodes?include=children |
| Section menu | GET /nodes?from=/blog&include=self,children |
| Breadcrumb | GET /nodes?from=/blog/second&include=ancestors,self |
| Previous and next | GET /nodes?from=/blog/second&include=siblings |
| Two-level mega menu | GET /nodes?maxDepth=2 |
fromis a node id or a published path. It defaults to the site root.includerepeats, and takesancestors,self,children,siblingsanddescendants. The default isself,descendants.maxDepthlimitsdescendants, counted from the site root.- Pass
site(arootNodeIdfrom/sites) orhost, andlocalefor a site that doesn’t take its locale from the address.
The response is flat and parent-linked:
{ "site": { "rootNodeId": "…", "name": "Example" }, "localeSlot": "default", "anchorNodeId": "…", "nodes": [ { "nodeId": "…", "parentNodeId": null, "depth": 0, "path": "/", "slug": null, "title": "Home", "titleSource": "content", "kind": "presentation" }, { "nodeId": "…", "parentNodeId": "…", "depth": 1, "path": "/blog", "slug": "blog", "title": "Blog", "titleSource": "content", "kind": "presentation" }, { "nodeId": "…", "parentNodeId": "…", "depth": 1, "path": "/about-us", "slug": "about-us", "title": "About us", "titleSource": "name", "kind": "redirect", "targetNodeId": "…", "targetPath": "/company" } ]}Nodes come in depth-first order, with siblings in the order editors arranged them. That order, like
the rest of the tree, is what was published: reordering pages in Composer shows here once the pages
are republished. depth is always counted from the site root, whatever you included.
kind says what a node is:
presentationis a page.GET /pathon itspathserves content.redirectforwards elsewhere. It carriestargetNodeId, andtargetPathwhen the target is published in the same site.structuralis an address with nothing to serve, such as a section that only groups pages. Render it as a heading without a link, and leave it out of anything that lists pages.
title is never empty. It is the page’s title for this address when an editor set one for it,
otherwise the title declared by the page’s content, otherwise the node’s own name in Composer.
titleSource is content for the first two and name for the last, in case you would rather show
your own default than an internal label.
A tree larger than 5,000 nodes answers 400 tree_too_large. Narrow it with from, include or
maxDepth; a partial tree is never returned silently.
With the SDK
Section titled “With the SDK”getNavigation(anchor, options) takes the anchor and the include list, and fills in site and
locale from the client. It returns the response wrapped in helpers: ancestors, children,
siblings (with { adjacent: true } for previous and next) and find, each accepting a path or a
node id. getTree() fetches the whole site once. In React, useNavigation paints from the browser’s
stored copy on a repeat visit and refreshes behind it.
// Navigation: breadcrumb, section nav, and a prev/next pager —// each one request, or one whole-tree fetch sliced locally.import { createContentClient, type NavigationNode, type NavigationTree } from '@ebitex/content-sdk'import { useNavigation } from '@ebitex/content-sdk/react'
const content = createContentClient({ apiKey: 'pk_…browser-safe…' })
// Scoped: one request per question. `anchor` is a node id or a published path.export async function breadcrumb(path: string): Promise<NavigationNode[]> { const tree = await content.getNavigation(path, { include: ['ancestors', 'self'] }) return [...tree.nodes]}
export async function sectionNav(sectionPath: string): Promise<NavigationNode[]> { const tree = await content.getNavigation(sectionPath, { include: ['self', 'children'] }) return tree.children(sectionPath)}
export async function pager(path: string): Promise<{ previous?: NavigationNode; next?: NavigationNode }> { const tree = await content.getNavigation(path, { include: ['siblings'] }) const [previous, next] = tree.siblings(path, { adjacent: true }) return { previous, next }}
// Or fetch the whole (small) site once and answer everything locally.export async function siteMapRows(): Promise<{ path: string; title: string }[]> { const tree: NavigationTree = await content.getTree() return tree.nodes // A `structural` node is a real address with nothing to serve, and a `redirect` forwards // elsewhere — neither belongs in a sitemap. .filter((node) => node.kind === 'presentation' && node.path !== null) .map((node) => ({ path: node.path as string, title: node.title }))}
// React: seeded synchronously from the persistent cache on mount, so a repeat visit paints its// navigation immediately and revalidates behind it.export function SiteHeader() { const { tree, loading } = useNavigation(undefined, { include: ['children'] })
if (!tree) { return loading ? <nav aria-busy="true" /> : null }
return ( <nav> {tree.nodes.map((node) => node.kind === 'structural' || node.path === null ? ( <span key={node.nodeId}>{node.title}</span> ) : ( <a key={node.nodeId} href={node.path}> {node.title} </a> ), )} </nav> )}The helpers answer only from what the response contains. ancestors() on a result you fetched with
include: ['children'] is an empty array, because the ancestors were never fetched.
Linking to pages
Section titled “Linking to pages”A component can be bound at one or more published addresses. Two options report them.
A list of items. listComponents({ paths: true }) (include=paths) adds a paths array to each
item, which is how a blog index links each card to its post:
// Listing with content and order: a blog index, newest first,// render-ready cards in a single request — no per-item getComponent follow-ups.import { createContentClient, type ComponentListItem } from '@ebitex/content-sdk'
const content = createContentClient({ apiKey: 'pk_…browser-safe…' })
export async function loadBlogIndex(cursor?: string): Promise<{ items: ComponentListItem[]; nextCursor: string | null }> { const page = await content.delivery.listComponents({ contract: 'blog-page', content: true, paths: true, // each card's link — the published node's own path resolve: 1, // the author/media references arrive expanded orderBy: 'publish-date', direction: 'desc', limit: 50, // Opaque — replay verbatim under the SAME orderBy/direction; on 400 invalid_cursor, restart. cursor, }) return page}The pages a document references. When a post names its series, or an article names a related
article, you want the link to that page. Set referencePaths: true on the client, and every
reference inside every document carries its target’s paths:
// Reference paths: linking to a page a document *references*, with no second query.//// A resolved reference carries the target's content. It carries the target's address too, once the// client asks for it — which is the difference between rendering the series' name and rendering a// link to the series.import { createContentClient, isResolved, pathForSite, type ComponentValue } from '@ebitex/content-sdk'import type { PresentationRenderer } from '@ebitex/content-sdk/react'
// One client-level option, not a per-call one: the resolved shape is part of the cache key, so a// per-call flag would split two callers resolving the same page into two requests.export const content = createContentClient({ apiKey: 'pk_…browser-safe…', referencePaths: true,})
interface BlogPost { heading: string series?: ComponentValue<{ heading: string }>}
const BlogPage: PresentationRenderer<BlogPost> = ({ component }) => { const { heading, series } = component.content const seriesRef = series && isResolved(series) ? series : undefined
// `paths` is the whole set of addresses this Component is published at — a page is normally // bound once, but a Component reused across sites is not, so the choice is the consumer's. // `pathForSite()` with no site named takes the first entry, which is right for a single-site // consumer; pass your own `rootNodeId` if you serve more than one. const seriesPath = pathForSite(seriesRef?.paths)
return ( <article> <h1>{heading}</h1> {seriesRef ? ( // Undefined is a normal state — the series page may not be published yet. Render the name // as plain text rather than a dead link. <p> Part of{' '} {seriesPath ? <a href={seriesPath}>{seriesRef.content.heading}</a> : <span>{seriesRef.content.heading}</span>} </p> ) : null} </article> )}
export default BlogPageIt is a client option rather than a per-call one, because the shape of a resolved page is part of
the cache key: a per-call option would split two readers of the same page into two requests. The
low-level client takes referencePaths on each read, and the endpoints take it as
include=referencePaths (see Resolve a path).
Each paths entry is { siteRootNodeId, path }, and an entry for a different site from the request’s
own also carries an absolute url. pathForSite(paths) picks one for you: the first entry, or the
entry for the site you name with pathForSite(paths, rootNodeId). It returns the url for a
cross-site entry and the path otherwise, and undefined when there is nothing to link to.
- An empty array is normal. The target may be a fragment that no page shows, or a page that isn’t published yet. Render the label as plain text instead of a dead link.
- References at any depth get addresses, expanded or not, so a shallow read is enough to build links.
- Live preview always includes
paths, whatever your client asked for. If a renderer relies onpathsand you forgetreferencePaths, links appear in Composer and not on the live site.
Sitemaps
Section titled “Sitemaps”GET /sitemap returns JSON, not XML: every page of one site that
belongs in a sitemap, with its path in each of the site’s locales. @ebitex/content-sdk/sitemap
turns that into sitemap XML. Serving the result is up to you: from a request handler, or written to
files by your build.
{ "site": { "rootNodeId": "…", "name": "Example" }, "defaultLocale": "en", "nodes": [ { "nodeId": "…", "path": "/", "lastmod": "2026-09-07T11:42:08Z", "localeSlots": [] }, { "nodeId": "…", "path": "/guide", "lastmod": "2026-09-08T16:03:55Z", "localeSlots": [{ "locale": "es", "path": "/guia" }] } ]}- Only real pages are listed. Redirects and structural nodes are left out, and so is any page an editor excluded from search in Composer, along with everything beneath it.
lastmodis when the content last changed, not when it was last published, so republishing a whole site does not tell crawlers that every page changed. It follows the page and the component bound directly to it; a change to content referenced further down does not move it, so it can be older than the truth but never newer.nullmeans unknown, and no<lastmod>is written.defaultLocaleis the organization’s root locale, used forx-default. It isnullwhen no locale tree is configured.
Call it with a server-side key: a browser-safe key is restricted to allowed origins, and a
server process sends no Origin header, so it answers 403 origin_denied. Pass both site and
env (both from listSites()) and the response can be cached at the edge; see
Caching.
// Sitemaps: the SDK gives you the data and the XML; serving it at a URL is yours. That boundary is// deliberate — we do not deploy your site, so any route adapter we shipped would encode assumptions// about a stack we do not control. Both recipes below are the whole integration.//// The boundary moves in exactly one place. `buildSitemapDocuments` returns the *set* of documents a// site needs — one when it fits, an index plus shards when it does not — and an index, unlike a// single sitemap, references its shards by URL. So the paths are agreed here. Where you mount them// is still entirely yours.import { createDeliveryClient } from '@ebitex/content-sdk'import { buildSitemapDocuments, buildSitemapXml, SitemapTooLargeError, type SitemapDocument,} from '@ebitex/content-sdk/sitemap'
// Use a server-side key (read it from your own environment): this runs outside a browser, and a// browser-safe key is origin-restricted, so it answers 403 to a request that carries no Origin// header — which a Node process never sends.const client = createDeliveryClient({ apiKey: 'sk_…server-side…' })
// Pass both `site` and `env` — from `client.listSites()` — for an edge-cacheable response.const SITE = '00000000-0000-0000-0000-000000000000'const ENV = '00000000-0000-0000-0000-000000000000'const ORIGIN = 'https://example.com'
/** * Reach for this by default, even on a small site. * * Under the protocol's caps it returns exactly one document, byte-identical to what * `buildSitemapXml` would have produced — so nothing is given up by starting here. Past them it is * the difference between a sitemap and an exception, and a function only large sites call is one * only large sites discover, by failing. */export async function renderSitemapDocuments(): Promise<SitemapDocument[]> { const data = await client.getSitemap({ site: SITE, env: ENV })
// `<lastmod>` needs nothing from you: each node carries the date its content last // actually changed, and the element is emitted only when one is known. It is not a publish // timestamp — a republish that changes nothing leaves it alone, which is what makes it safe to // advertise — and it can be older than the truth for a page whose content sits a further // reference deep, never newer. An index entry carries the latest among its own shard's nodes. const changed: (string | null | undefined)[] = data.nodes.map((node) => node.lastmod) void changed
return buildSitemapDocuments(data, { origin: ORIGIN, // Consumer policy about pages that genuinely exist. An editor can also exclude a page (and its // whole subtree) from Composer, in which case it never reaches you at all — the two compose, // and this list stays the right home for a decision that is yours rather than the content's: // a preview deployment that serves only part of a site, say. exclude: ['/search'], })}
// ---- 1. A request handler, for a site with a server -------------------------------------------//// Framework-agnostic by construction: nothing here imports a framework. One route serves every// document, and there is no branch on whether sharding happened — that is what the single-document// case using `indexPath` buys you. Do not add a cache of your own: the expensive part is already// cached at the edge and in the API's own response cache.
export async function sitemapResponse(pathname: string): Promise<Response> { const documents = await renderSitemapDocuments() const document = documents.find((candidate) => candidate.path === pathname)
if (!document) { return new Response('Not found', { status: 404 }) }
return new Response(document.xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'public, max-age=3600', }, })}
// ---- 2. A build script, for a statically-hosted site ------------------------------------------//// The identical call, written into the deploy artifact instead of a response. Correct on a site// whose build runs on publish; on one that fetches content at runtime and deploys rarely, the files// go stale against the content they describe — worth knowing before choosing this shape.
export async function writeSitemap( writeFile: (path: string, contents: string) => Promise<void>,): Promise<void> { for (const document of await renderSitemapDocuments()) { await writeFile(`dist${document.path}`, document.xml) }}
// ---- The single-document function, and the error it can still throw ---------------------------//// `buildSitemapXml` is not deprecated and is the right call when you know your site fits in one// file. It throws `SitemapTooLargeError` past *either* of the protocol's caps — 50,000 URLs and// 52,428,800 bytes uncompressed. The byte one arrives sooner than it looks: with `localeUrl` in// play every <url> carries the complete alternate set, so bytes grow with the square of the locale// count. A ten-locale site is over the byte limit at about 3,100 pages, with two thirds of the URL// allowance still unused.//// If you call it directly, handle that — or call `buildSitemapDocuments`, which is the remedy.
export async function renderSingleSitemap(): Promise<string | null> { const data = await client.getSitemap({ site: SITE, env: ENV })
try { return buildSitemapXml(data, { origin: ORIGIN }) } catch (error) { if (error instanceof SitemapTooLargeError) { // `error.reason` is 'urls' or 'bytes'; `urlCount` and `byteCount` say by how much. return null }
throw error }}
// ---- hreflang, if your site routes locales in URLs --------------------------------------------//// Omit `localeUrl` and no alternates are emitted at all, which is right for a site that serves one// locale per URL space. Supply it and every <url> carries the complete, self-inclusive alternate set// plus x-default.//// Note what the data does and does not say: a locale slot appears because the *site* materializes// it, and its path differs from the default when this page or an ancestor carries a localized slug.// Neither means the page's content has been translated — nothing in Content records that, so no// endpoint can report it. hreflang annotates which URLs exist; advertising them is your call.//// Which you make by returning `undefined`. See `partiallyTranslatedSitemap` below.
export async function localizedSitemap(): Promise<SitemapDocument[]> { const data = await client.getSitemap({ site: SITE, env: ENV })
return buildSitemapDocuments(data, { origin: ORIGIN, localeUrl: (path, locale) => (locale === 'en' ? path : `/${locale}${path === '/' ? '' : path}`), // Where the split files live. The defaults — /sitemap.xml and /sitemap-1.xml, /sitemap-2.xml … // — sit at the root, which is always safe. If you move them, remember that a sitemap may only // contain URLs at or below its own location, so a shard at /sitemaps/a.xml may not list /about. indexPath: '/sitemap.xml', shardPath: (index) => `/sitemap-${index}.xml`, })}
// ---- when only some pages are translated ------------------------------------------------------//// A slot materializes for *every* node once *any* node carries a localized slug, so a site with two// translated pages reports a French path for all of them. That is correct — the URL really does// resolve, and serves whatever the locale hierarchy falls back to — but advertising it as// `hreflang="fr"` claims a French page where there is an English one.//// Only you know which is which, so `localeUrl` lets you say: return `undefined` and that alternate// is omitted. A node left with fewer than two declares none at all, rather than linking to itself.
const TRANSLATED_INTO_FRENCH = new Set(['/', '/coffees', '/coffees/ethiopia-guji'])
export async function partiallyTranslatedSitemap(): Promise<SitemapDocument[]> { const data = await client.getSitemap({ site: SITE, env: ENV })
return buildSitemapDocuments(data, { origin: ORIGIN, // `node.path`, not `path`. The first argument is the path for *that locale*, which differs from // the default one whenever the page's Experience node carries a localized slug -- so a question // about the page keys on the node, and only the URL is built from `path`. localeUrl: (path, locale, node) => { if (locale === 'en') return path if (!TRANSLATED_INTO_FRENCH.has(node.path)) return undefined
return `/${locale}${path === '/' ? '' : path}` }, })}One file, or an index
Section titled “One file, or an index”A sitemap file is limited to 50,000 URLs and 52,428,800 bytes. With hreflang, every <url>
lists every locale, so the size grows with the square of your locale count: a ten-locale site passes
the byte limit at around 3,100 pages.
Start with buildSitemapDocuments, even on a small site. When everything fits it returns one
document at indexPath (/sitemap.xml by default), identical to what buildSitemapXml produces.
When it doesn’t, it returns a sitemap index plus shards (/sitemap-1.xml, /sitemap-2.xml and so
on), packed against both limits, with each page’s alternates kept together. Serve every document at
exactly the path it carries, because the index writes those paths into its own <loc> entries. If
you change indexPath or shardPath, remember that a sitemap may only list URLs at or below its
own location.
buildSitemapXml returns a single document and throws SitemapTooLargeError past either limit.
Its reason is 'urls' or 'bytes', with urlCount and byteCount.
exclude and filter remove pages for reasons of your own, such as a preview deployment that serves
only part of the site. They apply on top of what the API already left out.
hreflang
Section titled “hreflang”Without localeUrl, no alternates are written. With it, each <url> lists its complete set of
alternates, itself included, plus x-default. localeUrl(path, locale, node) returns the address
for one locale: a relative path is joined to origin, and an absolute URL is used as it is, which
suits a subdomain per locale.
A locale appearing in localeSlots does not mean the page is translated. Once any page in a site
has a slug in a locale, every page gets a path in that locale, and nothing in Content records whether
a page’s content has been translated. Only you know that, so localeUrl can say it: return
undefined and that alternate is left out.
- Decide on
node.path(ornode.nodeId), not onpath. Thepathargument is the address in that locale, which differs from the default one when the page has a localized slug. - A page left with fewer than two alternates declares none, rather than pointing at itself.
- A locale decision never removes a page. Return
undefinedfor every locale and the page is still listed at its own path. Return it for the default locale only and the page is listed at the locale URL that remained.
If the site is configured in Content with a locale addressing strategy, the data is already in your
URL space: under a path prefix every path carries its prefix, and under a host per locale every entry
carries an absolute url, which is written in place of what localeUrl returns. In that case,
return path unchanged, and undefined for pages you have not translated.