Public Demo Starter Architecture Guide
This guide explains the key patterns and conventions in the ApostropheCMS Public Demo starter. It pairs with the in-repo ARCHITECTURE.md quick reference and is aimed at developers who are new to ApostropheCMS and want to understand how the framework works before extending the starter.
The sections below cover the patterns you encounter in the first hour of working in the codebase: template discovery, the inheritance chain, the data object, area fields, link resolution, image rendering, and helper functions.
How ApostropheCMS Standalone Works
This starter is a single unified application: ApostropheCMS handles content modeling, the admin editing UI, server-side rendering, and asset serving in one Node.js/Express process. There is no separate frontend server. When a request arrives, ApostropheCMS selects the matching template, populates it with content data, and returns the rendered HTML directly.
Templates can be written in JSX (.jsx) or Nunjucks (.html). JSX is the recommended choice for new work; Nunjucks remains fully supported, and the two coexist in the same project. Apostrophe walks the module's view-folder override chain, preferring .jsx then .njk then .html within each folder — so a template in a nearer override folder wins regardless of its extension. See render().
Template Discovery
Templates are discovered automatically by filename — there is no registry to update. ApostropheCMS looks for templates at predictable paths relative to each module:
| Template | Path |
|---|---|
| Widget | modules/{module-name}/views/widget.jsx or .html |
| Regular page | modules/{module-name}/views/page.jsx or .html |
| Piece index | modules/{module-name}/views/index.jsx or .html |
| Piece show | modules/{module-name}/views/show.jsx or .html |
Note: A
.jsxtemplate can extend or include a.htmllayout using<Extend>or<Template>. A.htmltemplate cannot extend or include a.jsxtemplate — convert from the leaves up when migrating.
Template Inheritance
Every page and piece template slots its content into a shared outer shell via <Extend>. The chain has four levels:
data.outerLayout (Apostrophe's HTML shell — do not edit)
└── views/layout.jsx (site header, nav, footer — edit here for site-wide changes)
├── modules/{page-type}/views/page.jsx (regular page content)
└── modules/{piece-page}/views/
├── index.jsx (paginated piece index)
└── show.jsx (individual piece detail)views/layout.jsx is where most structural customization lives: the nav, header, and footer all live there. It extends data.outerLayout directly through <Extend>, so there's no intermediate Nunjucks layout in this demo — the layout itself is JSX, following the target-state pattern described in Writing a layout in JSX. index.jsx and show.jsx each extend views/layout.jsx independently — they are siblings, not children, of page.jsx.
A regular page template extends the layout and supplies its content as the main prop — layout.jsx declared that prop, so this is composition, not block overriding:
// modules/default-page/views/page.jsx
export default function({ page }, { Area, Extend }) {
return (
<Extend
templateName="layout"
main={
<div className="general-content">
<Area doc={page} name="main" />
</div>
}
/>
);
}Template Data
ApostropheCMS passes the same content data to every template. In a JSX template it arrives as the first argument to the exported function — destructure the properties you need. In Nunjucks the same values hang off a data object.
| Variable | JSX | Nunjucks | Contents |
|---|---|---|---|
| Page | page | data.page | The current page document |
| Piece | piece | data.piece | The current piece on show pages; null elsewhere |
| Global | global | data.global | Site-wide Global Settings — always available |
| Home | home | data.home | The home page; _children = top-level nav pages |
| Widget | widget | data.widget | The current widget document (widget templates only) |
The second argument is the helper object: { apos, helpers, Area, Component, Extend, Template, Widget }. Destructure only the helpers a given template actually uses.
Area Fields and <Area>
An area field is an ordered list of widgets that an editor can add to, remove from, and reorder without developer involvement. Because the backend controls the content schema, the area's definition — including which widgets editors are allowed to place — lives entirely in the backend module. The template's only job is to render an <Area> pointing at that field.
Backend schema:
// modules/default-page/index.js
import { fullConfigExpandedGroups } from '../../lib/area.js';
export default {
extend: '@apostrophecms/page-type',
fields: {
add: {
main: {
type: 'area',
options: {
expanded: true,
groups: fullConfigExpandedGroups // which widgets editors can add here
}
}
}
}
};JSX template:
// modules/default-page/views/page.jsx
// <Area doc={doc} name="fieldName" /> renders a CMS-editable widget sequence
// stored in that field. In edit mode, editors see the widget picker here;
// in view mode, widgets render normally.
export default function({ page }, { Area, Extend }) {
return (
<Extend
templateName="layout"
main={<Area doc={page} name="main" />}
/>
);
}ApostropheCMS wraps the area in editing controls in edit mode; in view mode it renders the widget templates directly.
Link Utilities
The starter uses a three-way link type (internal page, uploaded file, or custom URL). lib/link.js exports the canonical field set — spread it into any schema that needs a link rather than copying the fields manually:
import linkConfig from '../../lib/link.js';
fields: {
add: {
...linkConfig.link // adds linkType, _linkPage, _linkFile, linkUrl, linkTarget
}
}The modules/helper/index.js module centralizes resolution so templates never navigate _linkPage[0]._url by hand. apos.helper.linkPath() is a convenience method — call it from any template through the apos helper:
export default function({ widget }, { apos }) {
// apos.helper.linkPath() resolves any link object to a URL string —
// whether it points to a page, file, or custom URL.
return <a href={apos.helper.linkPath(widget)}>{widget.linkText}</a>;
}Reusable Components
Reusable markup that accepts arguments is an ordinary function component — this is what Nunjucks called a macro. The views/link template is the canonical example: it renders an <a> tag with the correct class, href, and target from any link object.
Render it by name with <Template>, which passes its props through as data:
<Template
templateName="link"
label={item.linkText}
path={apos.helper.linkPath(item)}
target={item.linkTarget}
className="button"
/>Because JSX templates are real JavaScript modules, you can also import a component directly, or define one inline in the same file:
import Link from '../../views/link.jsx';
function Badge({ label }) {
return <span className="badge">{label}</span>;
}Use <Template> when you want name-based lookup or Apostrophe's cross-module module:file syntax; use a direct import when the component is co-located. Either way, ordinary module scoping applies — a component imported in one template is not automatically in scope in templates that extend it. Each file imports what it uses.
Image Helpers
Rendering an image requires one extra step: _image is a relationship to image documents, not a URL. Each image document contains an attachment object with size variants, crop dimensions, and focal point data. Navigating that structure manually is fragile — it will break if internal field names change, and it won't handle cropped images correctly.
ApostropheCMS solves this with a two-step helper pattern. Never access _image[0].attachment directly — always use apos.image.first() followed by apos.attachment.url():
export default function({ widget }, { apos }) {
// _image is a relationship field — always an array, even when max: 1.
// apos.image.first() safely extracts the first attachment object.
const attachment = apos.image.first(widget._image);
const url = attachment && apos.attachment.url(attachment, { size: 'full' });
return url && (
<img
src={url}
width={apos.attachment.getWidth(attachment)}
height={apos.attachment.getHeight(attachment)}
srcset={apos.image.srcset(attachment)}
alt={widget.imageAlt || ''}
/>
);
}Default size strings: 'max', 'full', 'two-thirds', 'one-half', 'one-third', 'one-sixth'.
Conventions
The _ prefix. Any field whose name starts with _ is a relationship field that Apostrophe resolves at request time. These always come back as arrays, even when the schema says max: 1. Always check .length before accessing [0], or use apos.image.first() for image relationships:
{article._author.length > 0 && article._author[0].title}const attachment = apos.image.first(widget._image);lib/ utilities. lib/link.js exports the canonical link field set; spread it into any schema that needs a link rather than copying the fields. lib/area.js exports three area configurations for different editorial contexts — basicConfig, fullConfig, and fullConfigExpandedGroups — import the right one rather than defining widget lists inline.
i18n. Schema labels use the project: namespace by default (label: 'project:myField'). Translation files live in modules/@apostrophecms/i18n/i18n/project/. Add new keys there or introduce your own namespace with a matching folder.
Styling. Global Styles control site-wide design tokens (colors, spacing, typography) through the admin UI. Widget Styles provide per-instance CSS controls declared in a widget's styles property — they let editors change the look of individual widget placements without touching code. The two systems are complementary, not alternatives.
For deeper coverage of any of these topics, see the ApostropheCMS documentation.
Demo Content
If you elected to include demo content when setting up the starter, the project ships with a fully built-out site under the "Waypoint" brand — a fictional SaaS company. The live version is at astro-public-demo.apos.dev. Browsing it alongside the codebase is the fastest way to connect each template to what it renders on screen.
Five pages are pre-built:
- Home — the most widget-dense page, demonstrating hero, card grid, image/text split, stats row, custom GitHub open PRs display, article preview, and CTA callout.
- Pricing — a pricing table and feature comparison.
- About Us — cards, rich text, and an image with caption.
- Product Stories — the index page for the
articlepiece type, with category filter tabs for Insights, Behind the Scenes, Product Updates, and a custom category. - Case Studies — the index page for the
case-studypiece type.
Individual article and case study pages demonstrate the piece show template, where piece is populated instead of page.
The Waypoint content lives in the database, not in the code. Replace it by logging in to the admin UI and editing or archiving pieces and pages through the normal editorial workflow. For site-wide fields like the logo and footer links, update the Global Settings document from the admin bar.
ApostropheCMS localization works at two levels that are worth distinguishing. Content localization means each page and piece can have a separate version for each locale. Editors switch between locales using the locale picker in the admin bar and translate fields independently.
The three configured locales are English (no URL prefix), French (/fr), and German (/de), set in modules/@apostrophecms/i18n/index.js. When a page is available in multiple locales, ApostropheCMS automatically populates localizations with a _url, label, and flag for each one. The views/locales template reads that array directly to render the flag dropdown in the site header — no custom routing logic required.
String localization is a separate concern: it covers schema field labels, help text, and UI strings inside the admin. These live in modules/@apostrophecms/i18n/i18n/project/ as en.json, fr.json, and de.json. Any schema label prefixed with project: (e.g. label: 'project:articleBlurb') is looked up in the file matching the current admin locale. The adminLocales option in the same index.js controls which languages editors can choose for the admin UI itself, independently of the site's content locales.