The built-in header
The built-in header
With no HeaderPath, the header is the logo, the page menu, HeaderLinks and the feed link. Four
more settings shape it:
| Setting | What |
|---|---|
activeOn on a link |
Space separated site paths the link is current on. / is the home page alone; any other path covers itself and everything below it, so /docs is current on /docs/intro. A trailing slash is ignored. The current link gets aria-current="page" and the class bp-current. It can name a path the link does not point to: a chat server's link can be current on /community |
children on a link |
Links under this one, one level deep, each with its own label, href, badge, external and activeOn. Drawn as a dropdown that opens on hover and on keyboard focus and closes on Escape and when focus leaves, beside a button with aria-expanded that opens it too. A child's activeOn marks its parent current as well. The parent keeps its own href |
MenuLinks |
The rows of the phone menu, in the shape of HeaderLinks. Children are drawn as an indented group. Unset or empty, the phone menu shows HeaderLinks |
HeaderActions |
Up to four call to action links after the header links, each { label, href, variant } with badge and external as on any link. variant is primary (filled with the accent), secondary (outlined) or plain; unset or unknown is primary |
Below 48rem, a site that uses any of these gets a menu button in place of the header links and the
actions, opening a sheet with the MenuLinks rows, the feed link and the actions. The button is a
details element and every link is in the server's HTML, so the menu and the dropdowns work
without JavaScript; the script adds Escape and keeps aria-expanded true to what is shown. The
words on the controls are openMenu, closeMenu, menu and submenu in Labels (submenu is
{label} links, with the parent's label in place of {label}).
A site that sets none of them gets the header it had before, byte for byte.
A region page is chrome rather than somewhere to go, so it is left out of the menu and the sitemap,
and it answers 404 at its own route: what it holds is already on every page. A collection's
indexPage is treated the same way.
A header region replaces the whole band, the site name, the menu and the RSS link along with the top bar. There is no navigation or logo block yet, so a header region lists its own links until the block library has one.
A build-time site sets the same thing in its config, and a tenant's settings win over it:
export const config = defineConfig({
site: { name: "Mabini Clinic", url: "https://clinic.example" },
pages: "",
regions: { footer: { path: "/site/footer", tone: "surface" } },
});
The registry the regions render with is the one passed to createSiteLayout(config, { blocks }), and
the built-in blocks when none was passed.
Caching per tenant. Every read carries the tenant in X-Tenant and in its cache tag,
<cacheTag>:<tenant>, and a narrower tag beside it: <tenant tag>:entry:<type>:<slug> on a read of
one entry, <tenant tag>:type:<type> on a list, a search, the settings and the page tree. The webhook
purges the tags of the tenant its host resolves to, so point each tenant's webhook at
https://<that tenant's domain>/api/revalidate, signed with that tenant's own key (see
One key per tenant). A publish on one tenant leaves every other tenant's cached
reads in place.
When the CMS is down. Each successful read is also kept in the store, keyed by CMS, tenant and path.
A read that fails with a network error, a 5xx, or no answer within cmsTimeoutMs answers from the
last good copy and logs a warning; the next successful read replaces it. For ten seconds after such a
failure that read answers from the copy without asking the CMS, so an outage costs one request per
read every ten seconds rather than one per visitor. The same holds after a purge: a publish that lands
within those ten seconds shows once the ten seconds are up. Known hosts keep resolving the same way. A page that was cached
for a tenant keeps answering 200 with that tenant's identity and theme. A tenant never gets another
tenant's kept answer.
Holding mode. A tenant can show a holding page on every route in place of its site, for a launch,
maintenance or a seasonal break, and share the real site with a few people through site share links.
Three fields on the site entry control it:
| Field | Type | What |
|---|---|---|
Mode |
string | Live or Holding. Unset, or anything else, is Live |
HoldingPath |
string | A site path such as /coming-soon. The page the Pages module serves there is the holding page. Empty, or nothing served there, renders the default holding page: the name, the tagline and HoldingMessage, in the theme |
HoldingMessage |
string | The line the default holding page shows under the name and tagline, as text, for example Closed until 6 January. Unset or blank shows no line. Read only while holding. For more than a sentence, use HoldingPath |
Holding is presentation, not access control: the API still delivers every published entry. Content that must stay hidden before launch stays unpublished with a scheduled publish.
While a tenant is holding, for that tenant only:
- every page answers the holding page with
noindexandCache-Control: private, no-store. A page stops insiteConfigwith a not-found before it reads anything, so its content and its title never reach the response. Next has already sent the status by then, so it is 200, the same for a path that exists and one that does not; - the holding page is read with
GET /api/public/pages/resolve?path=<HoldingPath>and rendered with the page renderer and the registry passed tocreateSiteLayout(config, { blocks }); - a link to
HoldingPathis left out of the header, top bar and footer.feed.xmlandsitemap.xmlare 404 for everyone, session or not, androbots.txtanswersDisallow: /with no sitemap line; - route handlers (revalidate, blocks,
/_share,/api/share/redeem) and/_next/staticare served as usual.
Switching Mode is a publish: the webhook drops the tag the settings read carries and the next
request reads the new settings. No deploy.
Site share links. barakoCMS creates, lists and revokes them; anyone who may update the site type
can. A client is given {site Url}/_share#{key}. The key is in the fragment, so no server log, proxy
or referrer sees it:
/_shareis a tiny page. Its script reads the fragment, removes it from the address and history, and posts the key in a form to/api/share/redeem. Without JavaScript it says the link needs JavaScript. No key is ever accepted in a query string./api/share/redeemasks barakoCMS once,POST /api/public/site/share-links/redeemwith the key in the body and the tenant header. On 200 it sets__Host-press-share(HttpOnly, Secure, SameSite=Lax, Path=/, no Domain, so only that host gets it) and answers ano-store303 to/. On 404, 429 or any failure it sets nothing and sends the visitor to/#share-invalid, the holding page with "This link is not valid or has expired." A post whoseOriginorSec-Fetch-Sitenames another site is refused the same way. The key is never logged.- With a valid cookie that visitor sees the real site for that tenant. A forged, expired or other-tenant cookie is ignored.
The cookie holds no key. It is an expiry and an HMAC-SHA256 over the tenant and that expiry, so each later request is checked in process without asking the CMS, and a cookie made for one tenant opens no other. It needs one setting:
| Variable | What |
|---|---|
PRESS_SECRET |
The HMAC key, at least 32 characters, for example openssl rand -base64 48. Read per request. Unset or shorter, no session is issued or accepted and everyone gets the holding page. Every instance behind one domain needs the same value. PRESS_PREVIEW_SECRET is read in its place when PRESS_SECRET is unset. See One secret |
CMS_RENDERER_KEY |
Optional. Sent to barakoCMS as X-Barako-Renderer-Key when a link is redeemed, and must match the renderer key barakoCMS is configured with. Read per request and never logged. Unset, no key header is sent |
Redemption is rate limited per tenant and visitor. Every redemption leaves this container from the
same address, so barakoCMS needs the visitor's address to tell visitors apart. Name the header a
proxy in front sets it in, sites: { visitorIpHeader: "x-real-ip" }, and it is sent on as
X-Barako-Visitor-IP. Name it only behind a proxy that sets that header and strips a caller's value,
the same rule as hostHeader and tenantHeader. A value that is not exactly one IPv4 or IPv6
address, a comma separated list included, is not sent. barakoCMS trusts the address only when
CMS_RENDERER_KEY matches, so both are needed for a per visitor limit. With no header named nothing
is sent: a Next route handler never sees the socket's address, and the X-Forwarded-For Next adds
keeps whatever a caller put there.
A session lasts until the link expires or for 24 hours, whichever is sooner. Opening the link
again starts a new one while the link is valid. A revoked link can keep working for up to 24
hours for someone who already opened it, because the session is checked here, not in barakoCMS. To
end every session now, change PRESS_SECRET, which does it for every tenant on that
deployment, and changes every tenant's webhook key with it.
Whether a request gets the holding page is decided by the proxy, from the cookie, once per request,
and the answer is a segment of the path the render is kept under. A render made for a visitor with a
session is never served to one without, or the reverse, because the two are different paths. A page
you write by hand must call siteConfig(config, params) before it reads or renders anything, which
is what keeps it behind the holding page and what gives it its tenant. A build-time site has no
settings entry and no holding mode.
generateStaticParams factories return nothing on a request-time site, since there is no tenant at
build. scripts/two-hosts.sh runs the built reference app against a stand-in CMS with two tenants
and checks all of the above; CI runs it on every push.