Guides
Menus, site-wide content and lists
Read menus, site-wide entries such as a header or footer, index pages with pagination, filters and search, entries without an address such as FAQs, and references between entries, with the SDK.
How-to guide9 min read
Pages are only part of a website. This page covers the rest: menus, site-wide content, lists of entries and references between them. It uses the cms client from Connect a website. Every call here reads published content, and takes preview: true to read drafts while previewing.
Menus
Editors build menus in Menus, each with a key such as main or footer.
const menu = await cms.findNavigation({ key: 'main' })getNavigation({ key })CmsNotFoundErrorfindNavigation({ key })null| Method | When the menu does not exist |
|---|---|
getNavigation({ key }) | Throws CmsNotFoundError |
findNavigation({ key }) | Answers null |
Both take region, locale, preview and signal. A menu can be shared by every region or belong to one; ask with the region you are showing and the right one answers.
A menu has key, name and items. Each item has:
labelhrefnull for an item that only groups otherstarget_blank to open in a new tab, or nulliconnull. Map it to your own icons.badgenulldescriptionnullchildren| Property | Holds |
|---|---|
label | The text to show |
href | Where it goes: a page's current address, any address, or null for an item that only groups others |
target | _blank to open in a new tab, or null |
icon | An icon name editors chose, or null. Map it to your own icons. |
badge | A short label such as "New", or null |
description | A line of text for larger menus, such as a mega menu, or null |
children | Sub-items, in the same shape |
Items that link to an unpublished page are left out, and hidden items never arrive.
A menu with one level of sub-menus:
import Link from 'next/link'import type { CmsNavItem } from '@bifrost-orbit-cms/sdk'function Item({ item }: { item: CmsNavItem }) { const label = ( <> {item.label} {item.badge ? <span className="badge">{item.badge}</span> : null} </> ) return ( <li> {item.href ? <Link href={item.href} target={item.target ?? undefined}>{label}</Link> : <span>{label}</span>} {item.children.length > 0 ? ( <ul> {item.children.map((child) => ( <Item key={`${child.label}-${child.href}`} item={child} /> ))} </ul> ) : null} </li> )}export function SiteMenu({ items }: { items: CmsNavItem[] }) { return ( <nav aria-label="Main"> <ul> {items.map((item) => ( <Item key={`${item.label}-${item.href}`} item={item} /> ))} </ul> </nav> )}To mark the current page, compare item.href with the address in a client component using usePathname() from next/navigation.
Mark the menu for the preview so editors can open it from there: <header data-cms-edit="navigation:main" data-cms-edit-label="Menu">. See Set up the live preview.
Changing a menu sends navigation.changed, which refreshes cms:nav:<key> and every page that shows it.
Site-wide content
Content used on every page, such as the footer text, contact details, social links or the cookie notice, lives in global entries. Each has a key, such as footer.
const footer = await cms.findGlobal({ key: 'footer' })getGlobal({ key })CmsNotFoundErrorfindGlobal({ key })null| Method | When the entry does not exist |
|---|---|
getGlobal({ key }) | Throws CmsNotFoundError |
findGlobal({ key }) | Answers null |
Both take region, locale, strict, preview, include and signal. A global entry follows the language fallback, but not the region fallback.
A global has key, name, contentType, locale (the language that answered), fields and components. Type the fields with the generated type for its content type:
import type { FooterFields } from '@/cms/types'const footer = await cms.findGlobal({ key: 'footer' })const fields = footer?.fields as FooterFields | undefinedDraw its components with the same registry as pages:
<CmsBlocks blocks={footer?.components} registry={registry} />Read several at once so the layout waits for the slowest, not the sum:
const [header, footer, menu] = await Promise.all([ cms.findGlobal({ key: 'header' }), cms.findGlobal({ key: 'footer' }), cms.findNavigation({ key: 'main' }),])find… answers null when the entry is missing, but still throws when the CMS cannot be reached. To keep the website up during an outage, catch per call: cms.findGlobal({ key: 'footer' }).catch(() => null), or use Promise.allSettled.
Publishing any entry without an address refreshes cms:global, so every global is fetched fresh on the next visit.
Lists and index pages
list reads entries of one content type, such as blog posts or products, for an index page:
const posts = await cms.list({ type: 'blogPost', sort: '-publishedAt', perPage: 12, page: 1 })typepage1perPage20sortsortOrdersortOrder (the order editors set), name, publishedAt, updatedAt, each with - in front for the reversefilter{ activity: 'hiking' }. Exact, as text; a list field matches when it contains the value. All must match.tagtags field contains this valuecategorycategory field equals this valueqinclude['fields'] adds each entry's fields; ['references'] also expands references in themregion, locale, preview, signal| Option | Default | Means |
|---|---|---|
type | The content type key. Required. | |
page | 1 | Which page of results |
perPage | 20 | Up to 100. Larger values are capped. |
sort | sortOrder | sortOrder (the order editors set), name, publishedAt, updatedAt, each with - in front for the reverse |
filter | Field key to value, such as { activity: 'hiking' }. Exact, as text; a list field matches when it contains the value. All must match. | |
tag | Entries whose tags field contains this value | |
category | Entries whose category field equals this value | |
q | Entries whose name contains this text, ignoring case | |
include | ['fields'] adds each entry's fields; ['references'] also expands references in them | |
region, locale, preview, signal | As for pages. Lists never fall back to another language. |
The answer:
{ items: [{ id, url, name, contentType, publishedAt, seo, fields? }], total: 42, // matching entries across all pages page: 1, hasMore: true, // ask for page 2}To keep every entry of a type under one address, set Address on the content type in Schema › Content types, such as /blog. Every entry then lives at /blog/<slug>, whatever folder it is in, including new ones editors create. It can be set only when every entry already lives there, so no address changes, and removed only when the type has no entries. Make the index page the hub page of a folder with the same address, so editors find it beside the entries.
Items are summaries: no body. Link to each item's url, and render the full entry on its own page. An entry without an address has url and seo set to null. Global entries never appear in lists.
A paginated index page
import Link from 'next/link'import { cms } from '@/lib/cms'export const revalidate = 60type Props = { searchParams: Promise<{ page?: string }> }export default async function Blog({ searchParams }: Props) { const page = Math.max(1, Number((await searchParams).page) || 1) const posts = await cms.list({ type: 'blogPost', sort: '-publishedAt', perPage: 12, page }) return ( <main> <h1>Blog</h1> <ul> {posts.items.map((post) => ( <li key={post.id}>{post.url ? <Link href={post.url}>{post.name}</Link> : post.name}</li> ))} </ul> <nav> {page > 1 ? <Link href={`/blog?page=${page - 1}`}>Newer</Link> : null} {posts.hasMore ? <Link href={`/blog?page=${page + 1}`}>Older</Link> : null} </nav> </main> )}A folder such as app/blog takes priority over the catch-all page route, so /blog is this index while /blog/my-post still comes from the CMS. Reading searchParams renders the page on each request, but the CMS answer is still cached under cms:list. To keep the index fully cached, put the page number in the address instead, such as app/blog/page/[n]/page.tsx.
If editors also build the index page itself in the CMS, with an intro and components, render it from the catch-all route and add the list as a component that fetches it; see "Components that need more data" in Render components.
Every entry, across pages
async function allEntries(type: string) { const items = [] for (let page = 1; ; page += 1) { const result = await cms.list({ type, page, perPage: 100 }) items.push(...result.items) if (!result.hasMore) return items }}Entries without an address
Some entries are never pages, such as FAQ entries, team members or testimonials. Read them with their fields:
import type { FaqEntryFields } from '@/cms/types'const faqs = await cms.list<FaqEntryFields>({ type: 'faqEntry', category: 'pricing', include: ['fields'] })faqs.items.map((item) => item.fields?.question)The type parameter types fields. Fields arrive as on a page: images as objects, links with current addresses, rich text as HTML.
Filters and search
await cms.list({ type: 'product', filter: { activity: 'hiking', season: 'winter' } })await cms.list({ type: 'blogPost', tag: 'hiking' })await cms.list({ type: 'faqEntry', category: 'pricing', include: ['fields'] })await cms.list({ type: 'product', q: 'boots' })- Filter values are text: pass
'60', not60. A field key must start with a letter and contain only letters, digits and underscores. qmatches entry names only. For full-text search across bodies, send the entries to a search service, such as Algolia or Meilisearch, from your webhook route when content is published.- For a filter driven by visitors, read the choices from a taxonomy:
cms.getTaxonomy({ key: 'activity' })answers itsterms, each withvalue,labelandsortOrder.
References
Reference and References fields point at other entries, such as the author of a post or the questions in an FAQ block. By default they arrive as entry ids. Ask for the entries instead with include:
const result = await cms.resolve({ path, preview, include: ['references'] })resolve, getPage, getGlobal and list take it. Every Reference field on the entry and in any of its components then holds the entry, with id, url, name, contentType, publishedAt and fields:
export function FaqList({ block }: BlockProps<'faqList'>) { return ( <section data-cms-id={block.id}> {block.questions?.map((entry) => ( <details key={entry.id}> <summary>{entry.fields.question}</summary> <RichText value={entry.fields.answer} /> </details> ))} </section> )}- Type it: generate types with
--include references, soblock.questionsis typed asCmsEntry<FaqEntryFields>[]rather thanstring[]. Read every page withinclude: ['references']so the types are always true. - One level only: references inside a referenced entry stay ids.
- Unpublished entries are left out of a References list, and a Reference becomes
null. Previews show drafts of referenced entries too. - Language: each entry is read in the visitor's language, with the same fallback as the page.
- Refreshing: answers with references are tagged
cms:references, and every content change refreshes that tag, so a page showing an FAQ entry updates when the entry is published. - Live preview:
CmsLivePageasks the editor for references too when the page was read with them.
Without include, fetch referenced entries yourself with list and filter, or keep the ids for linking.
Regions, products and other content
- Regions and their languages:
cms.getRegions()andcms.getLocales(region); see Regions and languages. - Banners, popups and countdowns: see Render banners, popups and countdowns.
- Products:
cms.getProducts({ page, perPage, filter })answers products with prices and stock from the shop catalogue, in the same shape as lists. - Anything else, in one request: GraphQL through
cms.graphql(query, { variables }). GraphQL answers are never cached by the SDK; wrap them in your own cache if you use them on public pages.
Was this page helpful?