CMS v2 modules
Caraer CMS v2 turns a website page into an ordered list of modules: Astro components shipped by an app, configured by a content editor through typed input fields.
An editor sees a sidebar of inputs and a live preview of their real site. A developer writes an Astro component and declares which fields it exposes. Nothing in between is styled or structured by the platform.
This is v2 only. Companies on v1 keep the component-tree builder and the Next.js site, and nothing is migrated automatically.
The one constraint that explains the design
Section titled “The one constraint that explains the design”Astro compiles components at build time. There is no way to render an .astro
file from a string, a URL, or a package installed after the build.
So “which modules exist” is a build input, not request data. That splits the system cleanly:
| Baked into the build | Resolved per request |
|---|---|
| Which modules exist, and their code | Page content and field values |
| Framework island bundles | Design tokens and company branding |
| Menus, forms, record listings |
A site rebuilds when a company installs or removes an app, or when it promotes a pending module version. Publishing a new package no longer rebuilds every other company’s production site. Everything else, including a brand colour change, is a cache purge.
Writing a module
Section titled “Writing a module”A module lives inside an existing Caraer app, alongside functions/ and
webhooks/:
caraer apps add module herosrc/app/modules/hero/ index.astro the module: manifest and markup fields.d.ts generated; do not edit react/JobFilter.tsx islands live in a per-framework folderThe manifest
Section titled “The manifest”A module is one file. index.astro exports a manifest describing the fields
the editor sees, right above the markup that uses them:
---import CaraerRichText from '@caraer/cms-runtime/CaraerRichText.astro';import type { ModuleManifest, ModuleProps } from '@caraer/cms-runtime';
import type { HeroFields } from './fields.d.ts';
export const manifest = { name: 'hero', label: 'Hero', kind: 'section', category: 'hero', fields: [ { name: 'heading', label: 'Heading', type: 'SINGLE_LINE', required: true }, { name: 'job_title', label: 'Job title', type: 'PROPERTY_SINGLE_SELECT' }, { name: 'align', label: 'Alignment', type: 'SINGLE_SELECT', defaultValue: 'left', options: [{ name: 'left', label: 'Left' }, { name: 'center', label: 'Centered' }], }, ],} satisfies ModuleManifest;
const { fields } = Astro.props as ModuleProps<HeroFields>;
// A PROPERTY_SINGLE_SELECT arrives as the record's value, so on a vacancy page// this renders that vacancy's own title.const heading = fields.job_title || fields.heading;---
<section class="hero" data-align={fields.align}> <div class="caraer-container"> <h1>{heading}</h1> </div></section>
<style> .hero { padding-block: var(--caraer-space-section); background: var(--caraer-color-primary-100); }</style>satisfies ModuleManifest is what gives you autocompletion on field types and
an error on a typo, so keep it. The manifest has to be a literal: the CLI reads
it without running your code, so a value built from a variable or a function
call is rejected at validation.
kind decides where a module can be used:
| Kind | Used for |
|---|---|
section |
Composes into a page alongside other modules |
page |
A complete page in one module |
header |
The site-wide header slot |
footer |
The site-wide footer slot |
category decides how the library picker groups it. The set is closed, so the
same kind of block lands in the same group whichever app shipped it:
| Category | For |
|---|---|
hero |
The opening block of a page |
content |
Prose, headings and images |
listing |
A set of records, such as vacancies |
layout |
Site furniture: headers, footers, navigation |
media |
Video, galleries and other rich media |
form |
Collects a submission |
cta |
Drives the reader towards one action |
social_proof |
Testimonials, logos and reviews |
npm packages
Section titled “npm packages”A module can import npm libraries the same way any Astro project does. Put them
in the app’s root package.json dependencies — not devDependencies, and
not a folder-local package file.
cd your-appnpm install threeimport { WebGLRenderer, Scene } from 'three';caraer apps push copies those dependencies onto the published module package.
The company’s website build then installs them with the app. UI frameworks
(react, vue, …) stay declared on the module frameworks field so the build
hoists one copy; everything else rides with the package.
caraer apps local installs the same dependencies into the module preview
harness. Reinstall the harness after adding a library.
Field types
Section titled “Field types”Modules reuse the app settings field types, so the builder renders their inputs with widgets it already has. Available:
SINGLE_LINE, MULTI_LINE, SINGLE_SELECT, MULTI_SELECT,
RECORD_SINGLE_SELECT, RECORD_MULTI_SELECT, FORM_SINGLE_SELECT,
OBJECT_SINGLE_SELECT, OBJECT_MULTI_SELECT, PROPERTY_SINGLE_SELECT,
PROPERTY_MULTI_SELECT, SWITCH, MAPPING, FILE, MULTI_FILE, IMAGE,
COLOR, REPEATABLE.
SECRET and ACTION are rejected: a page document is public, so it must not
hold a credential, and a settings-screen button has no meaning on a page.
Modules also cannot use a serverless optionsSource, because the builder
renders field inputs without invoking the app runtime. Use static options.
advanced: true hides a field under a collapsed Advanced settings tile.
IMAGE is a file upload that only accepts images and shows a thumbnail.
COLOR stores hex, rgba(...), or a var(--caraer-color-*) token.
REPEATABLE is a list of objects: set itemFields, optional min / max /
itemLabel. Nested REPEATABLE is rejected.
Mark rendered nodes with data-caraer-field="heading" (and
data-caraer-item="0" on a REPEATABLE row) so a click in the builder preview
opens that sidebar field.
visibleWhen works exactly as it does for app settings.
Property binding
Section titled “Property binding”PROPERTY_SINGLE_SELECT is how a module reads from the record its page
belongs to. The editor picks a property; the module receives that property’s
value:
<h1>{fields.job_title}</h1> <!-- renders "Senior Developer" -->A listing module needs the property name instead, because it reads the same
property off each record it lists. That is on rawFields:
const titleProperty = rawFields.title_property as string;Reading the record directly
Section titled “Reading the record directly”A page usually belongs to a CRM record — a vacancy, a location, a person. Most
of the time you should reach it through a PROPERTY_* field, so the editor
chooses which property to show. When a module needs the record itself, it is on
Astro.props:
---const { record } = Astro.props as ModuleProps;
const location = record?.parsedProperties.location;const posted = record?.properties.published_at; // raw, e.g. an epoch---
{location && <p>Standplaats: {location}</p>}| Field | Contents |
|---|---|
record.uuid |
The record’s id |
record.object |
Object name, e.g. vacancy |
record.properties |
Stored values, e.g. an epoch for a date |
record.parsedProperties |
Display values a visitor would read |
record is null on a page with no linked record, so always guard it.
From client-side script
Section titled “From client-side script”An Astro <script> is bundled and has no access to server scope, so it cannot
read Astro.props. Use window.caraer, which carries the same record:
<script> const title = window.caraer?.getProperty('title');</script>| Method | Returns |
|---|---|
getProperty(name) |
The display value (what you almost always want) |
getParsedProperty(name) |
Same as getProperty |
getInternalProperty(name) |
The stored value |
record, page, company |
The raw objects |
This is the same API v1 exposed, so a company’s existing custom JS keeps
working unchanged. It is populated before the company’s own head and body
scripts run, and fires a caraer:ready event on document for anything that
loads later.
Prefer Astro.props.record where you can: it renders into the HTML, so it is
visible to crawlers and needs no JavaScript.
Design tokens
Section titled “Design tokens”Every page carries the company’s branding as --caraer-* custom properties on
<html>, resolved per request. Use them instead of hard-coded values so a
module looks native on every site that installs it.
.card { padding: var(--caraer-space-lg); border-radius: var(--caraer-radius-md); background: var(--caraer-color-gray-100); color: var(--caraer-color-font); font-family: var(--caraer-font-body); box-shadow: var(--caraer-shadow-sm);}| Group | Tokens |
|---|---|
| Colour | --caraer-color-{primary,primary-100,secondary,accent,font,background,destructive,black,white,gray-50…gray-900} |
| Spacing | --caraer-space-{3xs,2xs,xs,sm,md,lg,xl,2xl,3xl,section,gutter} |
| Radius | --caraer-radius-{none,sm,md,lg,xl,full} |
| Typography | --caraer-{font,size,weight,leading}-{h1…h6,body,small,nav,button} |
| Layout | --caraer-container-max-width, --caraer-header-height, --caraer-header-offset |
| Breakpoints | --caraer-breakpoint-{mobile,tablet,desktop} |
| Shadow | --caraer-shadow-{sm,md,lg} |
.caraer-container is provided by the page shell; use it rather than inventing
a container width.
Seeing what a token resolves to
Section titled “Seeing what a token resolves to”The table above is the vocabulary; it does not tell you what a given company renders with. Three ways to find that out, in rough order of usefulness:
| Where | Shows |
|---|---|
Tokens tab in caraer apps local dev |
Every token with its resolved value for the selected company, with swatches. Filter by name, click to copy the var(). Switch company to compare. |
@caraer/cms-tokens/tokens.css |
The platform defaults, as a plain CSS file. What you get with no company attached. |
CaraerTokenName in @caraer/cms-tokens |
The union of every token name, for autocompletion in TypeScript. |
Prefer the Tokens tab: a token is only interesting once it has a company’s value in it, and a colour that looks fine on one brand can be invisible on the next.
Platform components
Section titled “Platform components”Import from @caraer/cms-runtime for anything that needs backend access, so
your module never constructs an API client:
| Component | Purpose |
|---|---|
CaraerRichText |
Renders HTML, markdown or plain text with token-bound typography |
CaraerImage |
Resolves asset keys and expiring signed URLs |
CaraerLink |
Adds the locale prefix; inert inside the builder |
CaraerMenu |
Renders a company menu by location |
CaraerRecordList |
Server-rendered record listing |
CaraerForm |
Renders and submits a Caraer form |
Framework islands
Section titled “Framework islands”React, Preact, Solid, Svelte and Vue are all supported. Because React, Preact
and Solid all compile .jsx/.tsx, Astro can only tell them apart by path, so
JSX islands must live in a folder named after the framework:
src/app/modules/hero/react/JobFilter.tsxDeclare it in the manifest:
{ "frameworks": { "react": "^19.0.0" } }A build hoists one copy of each framework, so two installed apps cannot pull
different majors. The platform pins one major per framework per platform
version and caraer apps push rejects a mismatch, which keeps the conflict a
publish-time error for one developer instead of a build failure on a customer’s
live site.
The entry point is always .astro, and client:* directives must be written
statically inside it. Astro has to see the directive at build time to bundle the
island, which is why a module cannot be a framework component itself.
<JobFilter client:visible vacancies={vacancies} />Local development
Section titled “Local development”cd your-appcaraer apps local devRuns the app you are standing in: the serverless function server and the CMS module preview, whichever the app has. No company, backend or deployed build needed.
The preview renders your modules with an editable field sidebar and updates as
you type. PROPERTY_* fields resolve against a sample record, and menus, forms
and record listings return sample data, so a module using CaraerMenu or
CaraerRecordList still renders.
A throw or a syntax error in one module is contained to that module’s preview pane. The sidebar, module list and the rest of the chrome stay up, with a custom error card where the broken module would have rendered.
| Flag | Effect |
|---|---|
--company <subdomain> |
Company to select on start (see below) |
--record <object> |
Bind PROPERTY_* fields to a real record of that object |
--no-functions / --no-cms |
Run only one of the two |
--port / --cms-port |
Defaults 8787 and 4321 |
--file |
Point at a different app’s app.caraer.yaml |
--install |
Reinstall the preview’s dependencies |
Where the sample data comes from
Section titled “Where the sample data comes from”Two generated files under .caraer/cms-dev/, both rewritten on every run:
| File | Provides |
|---|---|
samples.json |
Starting value for each field. Your defaultValue wins; otherwise a placeholder per type, with SINGLE_LINE using the field’s own label. |
src/middleware.ts |
The record, company, design tokens, menus, forms and record listings. |
Editing either by hand is pointless — change defaultValue in
the manifest in index.astro instead.
Previewing against a real company
Section titled “Previewing against a real company”The picker in the top bar lists every company you can reach, and switching is instant. Sign in first, otherwise it offers sample data only:
caraer auth logincaraer apps local devPicking a company restyles the preview in its actual colours and typography, so you can check a module against a dozen brands in a few seconds.
That data never lands in your checkout. The CLI holds your credentials, so it serves the list and each company’s branding over a loopback address for as long as the preview runs, and fetches a company’s branding only when you select it. Stop the preview and it is gone.
Menus, forms and record listings come from the selected company’s live site.
Add --record to bind PROPERTY_* fields to a real record, the quickest way
to check a property binding against real data:
caraer apps local dev --company acme --record vacancy--company only chooses what is selected at startup; you can still switch
afterwards.
Anything the API cannot serve falls back to sample data and says so in the terminal, so a dropped connection degrades rather than breaks — but you always know which one you are looking at.
Only companies with a subdomain appear, since a company with no site has nothing to preview against.
Publishing
Section titled “Publishing”caraer apps validatecaraer apps push --deploypush uploads the staged module package through the Caraer API. You only
need caraer auth login — do not set npm tokens. The API publishes
@caraer/<app_name> (or @caraer/<company>_<app_name> for a private app)
and registers the catalog entry that makes the modules appear in the
builder’s library. Modules version in lockstep with the app build, so a
company that pins an app version gets exactly the modules that shipped with
it.
Removing a module from your app retires rather than deletes its catalog entry, because pages may still reference it. Those pages show “not installed” in the builder so an editor can remove them.
What happens on a customer’s site
Section titled “What happens on a customer’s site”The company that owns the app, and private apps, still rebuild production as soon as you publish. Every other CMS v2 install stays on the package version it has already accepted.
- Publishing snapshots an immutable
CmsModuleRelease(version, notes, module field schemas) and pins existing installs to the previous version if they have no pin yet. - Other companies get one in-app notification per app (
cms-module-update) with the release notes. A later publish replaces that notification. - An editor opens Website settings → Updates, previews a candidate Vercel
deployment (
CARAER_BUILD_CHANNEL=preview), then promotes. Promote applies only when the preview still matches the current pending set(appUuid, latestVersion). Intermediate versions they never previewed are skipped; their notes stay listed. - Installing or uninstalling an app still rebuilds that company’s production site immediately, because the company changed its own module set.
GET /api/v2/webpages/v2/build/manifest pins accepted versions. Pass
?channel=preview so a candidate build installs the latest pending packages.
- The build fetches that company’s installed app set, installs the pinned packages, and generates a registry mapping each module ref to its component.
- Requests resolve page content, field values and design tokens per request.
Check what a deployment actually compiled in:
curl https://<subdomain>.caraer.com/caraer/healthAI editing
Section titled “AI editing”There are two AI surfaces, and they write different things.
Rewrite (POST /api/v2/webpages/v2/pages/{recordUuid}/ai/rewrite) edits the
page document — field values and module composition. It never changes a
module’s index.astro. Astro resolves components at build time, so a hosted
module is a shared npm package compiled into {subdomain}.caraer.com. One
company’s prompt cannot rewrite caraer-core for every other tenant.
Modify this module is CMS v2 only: the company must be cut over
(cmsVersion >= 2) and the page trait must be v2. A staff sidecar preview on
a v1 company cannot fork, because the live Next.js site cannot compile the
package. Save targets a platform v2 private app.
It copies the installed package’s source into a warm playground, lets the editor prompt against those files, then saves the result as a new module in a private app:
POST .../ai/module-fork/sessionsseeds a playground session from the pinned tarball (app/module,packageName,version).POST .../sessions/{id}/promptrewritesindex.astro(and any extra module files). The manifest must stay a literal object: noSECRET/ACTION, no nestedREPEATABLE.POST .../sessions/{id}/savemerges the new module into the whole private-app package (catalog upsert retires names missing from the payload), publishes it, then appliesset_moduleso the current placement keeps itsidand points at{privateApp}/{newName}. Field values whose names still exist are kept; new required fields take the new manifest defaults.- The company Vercel site still needs a rebuild before
resolveModulecan render the new ref. Until/caraer/healthlists that ref, the builder keeps the playground preview. Closing early is allowed — the draft already points at the new module, the same “not installed until rebuild” state as installing any app.
Save never overwrites caraer-core. A later caraer apps push from an old
checkout of the same private app would republish without the AI module and
retire it.
The model only ever sees the company’s installed catalog and your declared field schemas, and Rewrite patches are re-validated server-side against those types.
Practical consequence for module authors: good label, helpText and
description text materially improves Rewrite results, because that text is
the whole description the model gets.