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" },
},
});