Global variables

Site-wide {{placeholder}} values — one document holds the phone number, company ID or address; content says {{phone}}; change the value once and it updates everywhere.

Not verified yet

Some values appear all over a site and must never drift apart: a phone number, an e-mail address, a company registration ID, an office address. Global variables keep each of them in one document; content refers to them as {{key}} placeholders; the site's build expands the placeholder wherever it appears. Change the value once, publish, and every page that mentions it updates.

The CMS gives this first-class authoring support: an insert-variable dropdown on text fields and resolved-value hints under any field that contains placeholders — so authors never have to remember what variables exist or what they're currently set to.

The division of labour

Two parties cooperate, and it's worth being precise about who does what:

  • The site owns expansion. Replacing {{phone}} with the value is the build pipeline's job (or the renderer's) — the CMS never rewrites your content. What lands in git is the placeholder, which is exactly what you want: the content stays source-of-truth-agnostic, and history diffs show "changed the phone number" in one file instead of twenty.
  • The CMS owns authoring. It knows where the variables live (via config), mirrors the list into the editor, offers insertion, and previews what each token resolves to. It also flags tokens that match no variable — those would render literally on the published site.

Setup

1. The variables document

A site-wide document holding an array of {key, value} items. Any path works; this is the conventional shape:

# src/content/settings/globals.md (frontmatter-only — or a .yml data file)
variables:
  - key: phone
    value: '+420 123 456 789'
  - key: phoneTel
    value: 'tel:+420123456789'
  - key: email
    value: hello@example.com
  - key: ico
    value: '12345678'

Give its collection area: site so it lives in the sidebar's Site settings group, where editors can add, rename and re-value variables like any other array field — creating a new variable needs no developer.

2. The root config pointer

Tell the CMS where the document lives, in .norcube/cms/config.yml:

variables:
  file: src/content/settings/globals.md
  field: variables # the array field; defaults to "variables"
KeyMeaning
fileRepo-relative path of the variables document. Required.
fieldThe frontmatter field holding the {key, value} array. Default variables.

3. Expansion in your build

The placeholder syntax is {{key}} — optional inner whitespace, keys are identifier-shaped ([a-zA-Z][a-zA-Z0-9_]*). Match the editor's semantics in your expander:

  • case-insensitive lookup ({{Phone}} finds phone),
  • unknown tokens stay as-is — never fail the build over a typo'd placeholder; the editor already flags it to the author,
  • expand recursively across every string field in a page's data, at a single central point, so individual components stay placeholder-unaware.

An Astro-flavoured sketch (any framework works the same way):

// lib/tokens.ts
import { getCollection } from 'astro:content';

const TOKEN_RE = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;

export async function createTokenExpander() {
  // read per render, not at module load — the CMS live preview edits
  // the document and the dev server must pick the new values up
  const doc = (await getCollection('globals'))[0];
  const values = new Map(
    (doc?.data.variables ?? []).map((v) => [v.key.toLowerCase(), v.value])
  );
  const expand = <T>(value: T): T => {
    if (typeof value === 'string')
      return value.replace(
        TOKEN_RE,
        (m, k) => values.get(k.toLowerCase()) ?? m
      ) as T;
    if (Array.isArray(value)) return value.map(expand) as T;
    if (value && typeof value === 'object')
      return Object.fromEntries(
        Object.entries(value).map(([k, v]) => [k, expand(v)])
      ) as T;
    return value;
  };
  return expand;
}

Read values per render, not at module scope

A module-level cache works in a production build but goes stale in the live preview's dev server: the author edits the variables document, the preview syncs it, and the old values keep rendering until a restart. Resolve the document inside the render path.

In the editor

With the config in place, three things light up:

  • The insert button. Text, link and paragraph fields grow a small {} button. It lists every variable — key in monospace, current value underneath — and picking one inserts {{key}} at the cursor, then returns focus with the caret after the token.
  • Resolved hints. A field whose value contains placeholders shows a line underneath with each token's current resolution ({{phone}} → +420 123 456 789). A token that matches no variable is flagged amber as unknown — it would render literally on the site.
  • Not inside the variables document. The paster and hints are suppressed while editing the variables document itself — a {{token}} inside a variable's own value wouldn't expand (expansion isn't recursive), so offering insertion there would mislead.
  • Opting a field out. Any text, link or paragraph field can declare variables: false in its schema to hide the paster and hints — for identifiers, slugs, or anywhere placeholders make no sense. Typing {{key}} by hand still expands on the site; the knob only controls the editor affordances.

Everything else is ordinary editing: the placeholder is just text in the field, Publish commits it verbatim, per-field history shows when a {{token}} was introduced, and the live preview renders the expanded value (because your site expands it).

Localized variables

Some variables differ per language — an address, opening hours, a tagline — while others never do (a company ID). The design principle: the token stays language-neutral, only the value localizes. Content says {{address}} on every language's page; the value resolves per locale:

translations[pageLocale] → translations[defaultLocale] → value

Model it in the variables document's schema with a checkbox and conditional fields — no special CMS feature needed:

- key: localized
  type: boolean
  label: Translate per language
  default: false
- key: value
  type: text
  label: Value
  # falsy, not `eq: false` — items saved before the checkbox existed have
  # no `localized` key at all, and `eq: false` doesn't match a missing key
  visibleIf: { field: localized, op: falsy }
- key: translations
  type: group
  inline: true # render the language inputs in place, no drill-in
  label: Translations
  visibleIf: { field: localized, op: truthy }
  whenHidden: drop # untick the box → the map is purged on save
  fields:
    - key: cs
      type: text
      label: Čeština (default)
    - key: en
      type: text
      label: English

Ticking Translate per language swaps the single Value input for one input per site language; an empty language falls back to the default language's value, so a half-translated variable never renders as nothing. On disk the item carries translations: {cs: …, en: …} instead of value:.

The editor resolves localized variables against the language of the page being edited — determined the same way preview deep-linking determines it (the per-locale folder, or the site default). The insert dropdown marks localized variables with a languages icon and shows the value for the current page's language; the hints do the same, and flag a variable that has no value for that language. Your build's expander implements the same chain (see the sketch above — index translations by the page's locale before falling back).

Limits and notes

  • Rich-text fields don't have the insert button yet. Typing {{key}} by hand works everywhere — expansion is the site's job — the dropdown just hasn't been built into the rich-text toolbar.
  • Values are plain strings. For a value that needs two renderings (display vs. href), use two variables — e.g. phone and phoneTel — rather than encoding logic into one.
  • The editor reads the variables document from the connected branch; a teammate's unpublished draft of the document isn't visible to your session until it's published.

Where to next

On this page