barakoCMS

One build, many sites

One build, many sites

barakoCMS D22 reverses "identity is build time" for the shared renderer: every site runs the same image, and what makes a site that site is its tenant's site settings entry, edited in barakoBrew.

export const config = defineConfig({ sites: {} });

A request-time site puts its page routes under one tenant segment and adds a proxy.ts beside app/:

proxy.ts                              export default createPressProxy(config)
app/
  %5Fpress/[site]/                    the tenant segment. %5F is `_`, which Next reads as a
    layout.tsx                        private folder when it is written plainly
    page.tsx
    [...path]/page.tsx
    blog/[slug]/page.tsx
  feed.xml/route.ts                   these resolve their own tenant and stay where they are
  robots.ts
  sitemap.ts
  api/...
  %5Fshare/route.ts
// proxy.ts
import { createPressProxy } from "barakopress";
import { config } from "@/press.config";

export default createPressProxy(config);

The proxy resolves the tenant and the share session once and rewrites to /_press/<tenant>~<gate>~<host>/<path>. The pages read all three out of that segment, so nothing in a page reads a header or a cookie, and Next can keep the render. Why the tenant has to be in the path, and what is cached and what is not, is The render cache below. A build-time site needs none of this and keeps its routes where they are.

On each request the engine resolves the tenant, reads that tenant's settings, and renders with them:

  1. CMS_TENANT (or tenant) set: every host is that tenant, with no lookup.
  2. sites.tenantHeader set and the request carries a valid handle in it: that tenant. Off by default.
  3. The host, from sites.hostHeader (default host), looked up with GET /api/tenants/by-host/{host}.
  4. sites.defaultTenant, or CMS_DEFAULT_TENANT, read at request time.
  5. None of those: a 404. Never another tenant's site.

A handle is only ever read from the request through a header the operator named. X-Tenant and X-Forwarded-Host sent by a caller are ignored unless you configure them, and you should configure them only behind a proxy that sets the header and strips a caller's value.

The settings are the singleton site type from barakoCMS docs/site-settings.md (POST /api/content-types/blueprints/site, then publish its one entry). The engine reads Name, Tagline, Url, Locale, Logo, LogoAlt, FooterLogo, Favicon, ShareImage, Copyright, Colors (the theme slots), Fonts (a family name per role, and the stylesheet that loads it), Radii, Layout, Tokens and Tones (see Tokens and tones), StyleRecipes (see Style recipes), TopBar, HeaderLinks, MenuLinks, HeaderActions, FooterColumns, SocialLinks, HeaderPath, HeaderTone, FooterPath, FooterTone, AssetsAsSupplied, LogoAsSupplied, LogoClearSpace, PageSizes, ReservedSlugs, Labels, HomePath and HomeCollection. Collections, OptionStyles and OptionColors are read as the collections section describes. Variants are not rendered yet. Every value is checked for shape; one that fails, and any the entry leaves out, keeps the configured value, so a half-filled theme renders. A link is a path on the site or an absolute http or https URL. Set Url: without it the feed and sitemap fall back to the host the tenant was found by. A link in HeaderLinks, TopBar or FooterColumns may also carry badge, a marker of up to 12 characters drawn beside the label, and external: true, which the built-in header does not need (every header link is already a plain anchor) and a theme can read. A link in HeaderLinks or MenuLinks may also carry activeOn and children; see The built-in header.

createSiteLayout and createSiteMetadata render the root layout from all of this: lang, the faces, the palette, the top bar, header links, footer columns, social links and the copyright line.

What the site serves at /. Every site used to be a blog at the root, because the root route mounted the post index and nothing else could be named:

Field Type What
HomePath string A site path such as /home. The page the Pages module serves there is the home page. Nothing served there falls back to the index, so naming a page before writing it is safe
HomeCollection string The key of a collection. Its index is the home page. HomePath wins when both are set

Neither set, / is the post index, which is what it was. The blog's own post, author and category are ordinary Collections entries now, so a school whose news lives in article with a Headline replaces post in its settings and gets both its list and its item pages from that entry. The RSS link in the built-in header, and the feed alternate in the page metadata, appear only when some collection has feed on, so a clinic with no posts stops advertising an empty feed.

The words a visitor reads. Labels is the visitor-facing copy, key by key. A school setting Locale to fil-PH used to get Filipino dates beside English "min read" and "Related":

{ "Labels": { "minRead": "minutong pagbasa", "by": "ni", "related": "Kaugnay" } }
Key English
minRead min read
by by
related Related
relatedNote cosine similarity, computed on load, not curated
featured Featured
back Back
home Home
preview The banner over a draft being previewed
untitled Untitled
feed RSS
empty, emptyNote The notice on an index with nothing published
failed, failedNote The notice on an index whose read failed
shareInvalid This link is not valid or has expired.
openMenu, closeMenu Open menu, Close menu: the header's phone menu button
menu Menu: the name of the phone menu's links
submenu {label} links: the button beside a header link with children, {label} its label
onThisPage On this page: the heading over a tree page's rail
closeContents Close: the closed phone disclosure's control while it is open

A key left out, or saved as anything but a word, keeps the English, so a half-filled map reads. A build-time site passes labels to defineConfig. Nothing about a site's own content is here: a collection's heading is its label and how its count reads is its noun.

The colour slots, by role. Colors sets any slot of the palette, one at a time. The slots are pageBg, surface, ink, proseInk, secondaryInk, muted, hairline, accent, accentHover, accentInk, accentTint, accentTintBorder, accentBorderStrong, inverse, inverseChrome, inverseInk, inverseAccent, code and success. A role says where a colour goes rather than what it looks like: inverse is the band that reverses the page, the footer and a code panel and an inverse block, so a bakery with a cream footer sets inverse to cream and reads right doing it.

{ "Colors": { "accent": "#17458F", "inverse": "#F4E3C1", "inverseInk": "#3B2A17" } }

Six slots shipped in 0.3.0 under barakocms.com's own names, and those still work: darkPanel, darkPanelChrome, darkPanelInk, darkPanelAccent, codeGreen and accentTintBorderStrong are read into inverse, inverseChrome, inverseInk, inverseAccent, code and accentBorderStrong. Setting either name sets both, so a tenant saved before the rename keeps its site and a consumer's own component reading theme.colors.darkPanel keeps compiling. The old names are deprecated and go in 2.0.0. An OptionStyles or OptionColors entry naming an old slot resolves too.

Sizes work the same way. Text is the type scale by role, meta, small, body, lead, subheading, heading, title, display and pageTitle, and Space is the spacing scale. The blocks and the screens read those names, so a tenant that wants bigger headings sets title once instead of asking for a release.

How many items, per tenant. PageSizes sets the four counts for this site, each on its own:

{ "PageSizes": { "index": 50 } }

A bakery listing 50 products and an agency listing 9 case studies run the same image. A key the entry leaves out keeps the configured count, and a collection's own pageSize still wins over the site's. barakoCMS clamps a public list at 100 whatever is asked for, so a larger number is not an error and does not buy more rows.

Paths this site does not serve. ReservedSlugs adds to the configured reserved list:

{ "ReservedSlugs": ["shop", "status"] }

A first segment on it is left out of the menu and the sitemap, and a page there is never asked for. It is what a proxy in front of this domain answers instead of the renderer, which is a fact about one tenant rather than about the image. It only ever adds: a tenant cannot free a segment the app's own routes already hold, because a page there would sit behind a route file and render nowhere. A tenant's collection routes need no entry, since those are read off its settings already.

Fonts, from an allow list. A family name on its own is loaded from Google Fonts, which is what it has always meant:

{ "Fonts": { "heading": "Zilla Slab" } }

A site that cannot use Google Fonts, a school with a licensed face on its own host or a tenant that must not send visitor addresses to a third party, names the stylesheet instead:

{ "Fonts": { "heading": { "family": "Zilla Slab", "url": "https://type.school.example/zilla.css" } } }

That URL is a tenant's setting on its way into a <link> in every visitor's page, so which origins a page may reach is the deployment's decision and not the tenant's. PRESS_FONT_ORIGINS is the list: origins separated by commas or spaces, each https://host, a bare host read as https.

PRESS_FONT_ORIGINS What a page may link
unset or blank Google Fonts, and nothing else. This is what every site rendered before the list existed
https://type.school.example That origin only. Nothing goes to Google Fonts, the built-in link and its preconnects included
fonts.googleapis.com, type.school.example Both

A URL on any other origin is refused: no link to it is rendered, the role falls back to its family name, and the server log says so once rather than once a page view. The list replaces the default rather than adding to it, which is what gives a tenant that must not reach Google Fonts a deployment where nothing can. createSiteLayout(config, { loadFonts: false }) still turns off every font link for the whole image.

Only an absolute https URL is kept, for the whole chain: http is blocked as mixed content on every site this serves, so allowing it would mean rendering a link that never loads. A role the tenant names is the tenant's, family and stylesheet together, so a family set with no url clears a configured stylesheet rather than leaving the page loading a face it no longer uses. A build-time site sets the same thing in theme.fontSources, and it is held to the same list.

Header and footer as block regions. The built-in header and footer take links and text and nothing else, so a clinic that wants a light footer with opening hours and a map cannot have one, and a school that wants an enrolment banner with a button cannot either. Point a region at a page and its blocks are drawn there instead, resolved and bound exactly as the page route resolves and binds them.

Field Type What
HeaderPath string A site path such as /site/header. The page served there is drawn in place of the top bar and the header band
HeaderTone string page, surface, accent, inverse, gradient, wash, or a name from Tones: the tone behind the header region. page when unset or not one of those
FooterPath string A site path such as /site/footer. The page served there is drawn in place of the footer
FooterTone string The same names, behind the footer region

Set neither and nothing changes: TopBar, HeaderLinks, FooterColumns, SocialLinks and Copyright draw the built-in chrome with the markup they always had, which is what keeps a site whose own CSS keys off that markup rendering. A path with nothing served at it does the same, so naming a page before writing it is safe, and so is a typo.