Skip to content

Editable content collections

Mail bodies, canned replies, AI prompts, legal copy: text that should change without a deploy. The template-resolver stores all of it. A collection is what makes a slice of it editable — it appears in the navigation and comes with list, detail and save handlers.

Two decisions per collection: who owns the entries, and how they are edited.

The default. Every user with the right role sees and edits the same entries — help texts, legal pages, mail templates for the whole tenant.

createTemplateResolverFeature({
collections: [
{
id: "snippets",
kind: "text-block",
access: { roles: ["Admin", "Editor"] },
nav: { parent: "content" },
contentFormat: "rich",
},
],
});

access sits at the mount, not in the bundled feature — only your app knows its own role names. Each collection gets its own snippets-list / snippets-item / snippets-set handlers carrying exactly that rule, so the dispatcher keeps two collections apart even when both hold text-block records.

Set ownership: "user" and each user edits their own entries. Mail signatures are the obvious case — two agents both have a standard signature, and neither sees the other’s.

createTemplateResolverFeature({
collections: [
{
id: "signatures",
kind: "text-block",
ownership: "user",
access: { roles: ["Agent"] },
nav: { parent: "settings" },
contentFormat: "rich",
},
],
});

Two things come with that choice:

  • Those rows live in their own entity (user-content-entry) and count as personal data. Mount template-resolver-user-data alongside, or the boot check stops the app.
  • The entity needs a migration on your side. kumiko-schema generates it; nothing to write by hand.

contentFormat decides:

  • plain — a text area. Right for AI prompts and anything where markup would only get in the way.
  • rich — a small WYSIWYG: bold, italic, headings, lists, links. Stores HTML, because mail and PDF rendering want HTML anyway. No tables, no image uploads.

Both show the collection’s variableSchema as chips you click to insert, and a preview rendered with sample data — an editor sees what {{firstName}} turns into without sending a mail.

The rich editor: bold/italic/heading/list toolbar, a Preview button, and the collection's variables as chips below the content field. The sidebar shows both collections — Snippets shared by the tenant, Signatures owned per user.

If neither fits, register your own component for a format in your client feature and it wins over the built-in one:

export const myClientFeature = {
name: "my-app",
contentEditors: { rich: MyEditor },
};

You pick the collection id, so you also ship its navigation label. The bundled feature cannot translate a name it never chose. Put the keys in a feature that is part of APP_FEATURES — a client-side screens feature never reaches the server-side boot validator, and the missing key stays invisible until someone opens the nav.

r.translations({
keys: {
"templateResolver:nav.signatures": { de: "Signaturen", en: "Signatures" },
},
});