Skip to content

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.

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
  • from is a node id or a published path. It defaults to the site root.
  • include repeats, and takes ancestors, self, children, siblings and descendants. The default is self,descendants.
  • maxDepth limits descendants, counted from the site root.
  • Pass site (a rootNodeId from /sites) or host, and locale for 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:

  • presentation is a page. GET /path on its path serves content.
  • redirect forwards elsewhere. It carries targetNodeId, and targetPath when the target is published in the same site.
  • structural is 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.

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.tsx
// 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.

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.tsx
// 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.tsx
// 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 BlogPage

It 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 on paths and you forget referencePaths, links appear in Composer and not on the live site.

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.
  • lastmod is 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. null means unknown, and no <lastmod> is written.
  • defaultLocale is the organization’s root locale, used for x-default. It is null when 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.

sitemap.ts
// 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}`
},
})
}

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.

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 (or node.nodeId), not on path. The path argument 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 undefined for 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.