Skip to content

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.

A module lives inside an existing Caraer app, alongside functions/ and webhooks/:

Terminal window
caraer apps add module hero
src/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 folder

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

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.

Terminal window
cd your-app
npm install three
import { 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.

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_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;

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.

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.

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.

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.

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

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.tsx

Declare 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} />
Terminal window
cd your-app
caraer apps local dev

Runs 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

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.

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:

Terminal window
caraer auth login
caraer apps local dev

Picking 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:

Terminal window
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.

Terminal window
caraer apps validate
caraer apps push --deploy

push 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.

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.

  1. Publishing snapshots an immutable CmsModuleRelease (version, notes, module field schemas) and pins existing installs to the previous version if they have no pin yet.
  2. Other companies get one in-app notification per app (cms-module-update) with the release notes. A later publish replaces that notification.
  3. 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.
  4. 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.

  1. The build fetches that company’s installed app set, installs the pinned packages, and generates a registry mapping each module ref to its component.
  2. Requests resolve page content, field values and design tokens per request.

Check what a deployment actually compiled in:

Terminal window
curl https://<subdomain>.caraer.com/caraer/health

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:

  1. POST .../ai/module-fork/sessions seeds a playground session from the pinned tarball (app/module, packageName, version).
  2. POST .../sessions/{id}/prompt rewrites index.astro (and any extra module files). The manifest must stay a literal object: no SECRET / ACTION, no nested REPEATABLE.
  3. POST .../sessions/{id}/save merges the new module into the whole private-app package (catalog upsert retires names missing from the payload), publishes it, then applies set_module so the current placement keeps its id and points at {privateApp}/{newName}. Field values whose names still exist are kept; new required fields take the new manifest defaults.
  4. The company Vercel site still needs a rebuild before resolveModule can render the new ref. Until /caraer/health lists 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.