Documentation
Content model
Every page is a JSON file listing the components it renders and the content those components need. This is the part of Vertex you'll spend the most time in.
Flat keys with array indices
Page files use flat dot-notation keys rather than nested objects. The framework inflates them into a nested pageContent object at render time. Flat keys keep diffs readable — moving a section is a one-line change, and a translation file can override a single deeply-nested string without restating its parents.
{
"components[0]": "Header",
"components[1]": "Hero",
"components[2]": "Contact",
"components[3]": "Footer",
"content.hero.headline": "Get in touch",
"content.hero.cta.text": "Email us",
"content.hero.cta.href": "mailto:hi@example.com",
"path": "/contact"
}How components read content
Each component declares a configKey and reads its block from the injected pageContent. A component named Hero with configKey "hero" reads everything under content.hero.*.
<script setup>
import { computed, inject, ref } from 'vue';
const pageContent = inject('pageContent', ref({}));
const hero = computed(() => pageContent.value?.hero || {});
</script>
<template>
<section v-if="hero.headline">
<h1>{{ hero.headline }}</h1>
<a v-if="hero.cta?.href" :href="hero.cta.href">{{ hero.cta.text }}</a>
</section>
</template>The silent-render rule
Most components are gated against their own content block. A component listed in components[] with no matching content.<configKey>.* keys renders nothing — silently, with no build error and no console warning.
This is deliberate: it lets a shared components list work across pages where only some sections have content. But it means a missing section is almost always a missing content key rather than a broken component. Check the page JSON first.
Shared content
shared.json holds content used by every page — typically the header navigation and footer. Page files can override any shared key locally, so a landing page can carry a different footer without touching the others.
{
"content.header.navItems[0].text": "Docs",
"content.header.navItems[0].href": "/docs",
"content.header.navItems[1].text": "GitHub",
"content.header.navItems[1].href": "https://github.com/koehler8/cms",
"content.header.navItems[1].target": "_blank",
"content.footer.text": "Built with Vertex CMS."
}Locales
Each locale is a directory under site/content/ that mirrors the base locale's structure. content.config.json names the base. The base locale loads first and each other locale overrides only the keys it defines, so a half-finished translation still renders — untranslated strings fall back rather than disappearing.
Only locales with content on disk get routes. The base locale serves at /path; the others serve at /{locale}/path, and hreflang alternates are emitted automatically.
content.config.json { "baseLocale": "en" }
en/
site.json
shared.json
pages/home.json
de/
pages/home.json # overrides only the keys it defines
ja/
pages/home.jsonPage metadata
meta.* keys on a page control its title, description, and social card. Canonical URLs, hreflang alternates, and breadcrumb JSON-LD are derived automatically from the page's path — you don't author them.
{
"meta.title": "About us — Example",
"meta.description": "Who we are and what we build.",
"meta.image": "/og-about.jpg",
"meta.ogType": "article",
"meta.breadcrumbs": false,
"path": "/about"
}Drafts
Gate a page, a path prefix, or the whole site behind a password. Page-level wins over site-level, so a single page can be force-published during a site-wide draft by setting draft to false.
A gated page ships with a noindex meta tag, a Disallow line in robots.txt, and omission from the sitemap — and its body only mounts client-side after unlock, so the HTML on disk contains just the gate. One caveat: the page's content keys are still embedded as a hydration blob, so drafts are a soft gate. Don't put genuinely sensitive content in a page file before launch.
{
"draft": false,
"draftPaths": ["/blog", "/regions/california"],
"draftPassword": "replaced-with-a-sha256-hash-at-build"
}