How Inka Works

Inka is the page-building and governance layer between your CMS and your front ends. Content stays in the CMS, rules come from your design system, and front ends in any framework render the result. Editing is private; publishing is public.


Your design system as blocks and rules

Pages are laid out in blocks, and the blocks are your design system. Each block type is a component your developers already wrote in the front end. With each one go the rules for using it:

  • What can go inside what. Each region lists the block types it accepts, and how many (allowedBlocks, maxLength).
  • Which fields an editor can change. A block's schema lists its fields; templates and layouts can lock blocks so they can't be moved or edited (fixed, readOnly).
  • What those fields accept. Field rules check a value and either block the save or give advice.
  • Which combinations are not allowed. A rule can compare one field with another, so a setting that only works with certain others can be refused.

Editors get a page builder that only offers what your design system allows. They drag, drop and type on the rendered page, on a phone as well as a desktop (see editing on a phone), but there is no freeform mode to fall out of, so an off-system page is not something anyone has to catch in review. Rules and checks has an example of each kind of rule.

Three layers

Inka splits a site into three layers, and each can change without the others:

  • The CMS, such as Plone, Drupal or WordPress, keeps the content, workflow, users, permissions, scheduling and translation. It connects to Inka through an adapter.
  • Inka does the page building and applies your design system's rules while people edit. It is not a CMS and stores no content of its own.
  • Front ends render the pages, in any framework, as a static site (SSG), server-rendered (SSR) or a single-page app (SPA). One site can have several.

This is more decoupled than a typical headless CMS. A headless CMS still owns the page builder, so its editing screens decide what a page can be. With Inka, the front end decides. Each front end declares its block schemas and rules through the bridge script (initBridge), and Inka applies them while people edit. Change your design system and the editing rules change with it, with no CMS release. Change the CMS and keep the editor and the front ends; rebuild a front end and keep the CMS and the rules.

                        ┌───────────── behind login, can sit behind a VPN ──────────────┐
┌───────────────┐       │   ┌────────────────┐   postMessage    ┌───────────────────┐   │
│ CMS           │ save  │   │ Inka editor    │◄────────────────►│ Front end,        │   │
│ Plone,        │◄──────┼───┤ page building, │    (iframe)      │ editing build     │   │
│ Drupal,       │ read  │   │ design rules   │                  │ + bridge script   │   │
│ WordPress     ├───────┼──►└────────────────┘                  └─────────▲─────────┘   │
│ (adapters)    │ read  │                                                 │             │
│               ├───────┼─────────────────────────────────────────────────┘             │
│               │       └───────────────────────────────────────────────────────────────┘
│               │ read, published content only        ┌───────────────────┐
│               ├────────────────────────────────────►│ Public front end  │  no Inka, no bridge
└───────────────┘                                     └───────────────────┘

More than one content store

An adapter connects Inka to a content store through one contract, so editors get the same experience whatever sits behind it: Plone, Drupal, WordPress or a custom store. Different sections of one site can come from different stores, for example news from one system, a product catalogue from another and policies from a third. Editors work on all of it in the same editor. If your content lives somewhere that isn't a CMS, write an adapter for it; it uses the same contract as the built-in ones. See the CMS adapter guide.

More than one front end

Nuxt, Next.js, Astro, Vue and Framework7 front ends work today, and so do server-rendered sites in PHP, Django, Rails or Laravel (see server-rendered front ends). A front end adds one small JavaScript file, the bridge, and a few HTML attributes on the elements editors can change. There is no SDK to adopt and no framework to switch to.

One site can have several front ends reading the same content, for example the main website and a mobile app. Editors switch between them in the toolbar while they edit, to see the same page rendered by a different front end and design system.

Editing is private, publishing is public

Inka is only used to edit. The public site never talks to it.

  • Inka sits behind login. Editors sign in with their CMS account, so single sign-on, roles and approval workflow are the CMS's, used as they are. You can also put Inka behind a VPN, because nothing public needs to reach it.
  • The public site is a read-only front end. It reads published content straight from the CMS API and never loads Inka.
  • The bridge script is only for editing. Load it only when the page is opened inside Inka's editing iframe, or ship it only in a separate editing build of your front end. Your public build does not need it.
  • Edits travel over postMessage. Inka opens your front end in an iframe. The bridge and Inka talk only through postMessage, and each side checks the other's origin: the bridge sends only to the Inka origin, and accepts messages only from that origin or from its own page.
  • Inka saves to the CMS, the bridge does not. When an editor saves, Inka writes the page through the CMS's API with the editor's own login, so the CMS's permissions and workflow apply. The front end only reads: it fetches content with the editor's token so the preview can show unpublished pages, and the bridge sends the edits to Inka.

Deploy and secure Inka covers the settings that keep this split tight, such as frame-ancestors on the editing front end.

Rules while editing

Rules run as the page is written, not in a review afterwards. Field rules do one of two things:

  • A hard rule stops the save. A required field left empty, or a value that cannot work (a field rule with error), marks the field invalid, and the page can't be saved until it is fixed.
  • An advisory rule gives advice. A field rule with warning shows beside the field while the editor works, and the page still saves.

Region rules (allowedBlocks, maxLength, allowedStyles) act earlier still: the editor is only offered the blocks and text styles the region accepts.

The same mechanism covers more than layout. Accessibility and other standards you are held to, the structure of your design system, and what the site says, such as words your brand avoids, can all be written as rules. See Rules and checks.

A machine can check colour contrast or heading order. It can't tell whether alt text says anything useful, whether the reading level suits the audience, or whether a photo belongs on the page. Those need a person's judgement. Inka Assure, in development and planned for late 2026, will save that judgement against the version of the page it applies to: who signed it off, when, and on what basis. When the page changes, the item will come back for review. Overrides will be recorded the same way.

AI agents

AI agents connect through MCP (Working with an AI agent). An agent connects with the access of the editor who signed it in, and works through the editor itself, so it is held to the same rules. There is no separate path for agents, so an agent can't write its way around the rules.

An agent that comes in through the same door can be asked to check as well as write, for example cross-checking a claim against your own documents, or finding a figure that doesn't match its source. You set which checks run and which need a person to sign off.

Inka is stateless

Inka has no database and no content store of its own. Content, users and history live in the CMS. Each editor's own settings, such as the front ends they have added, live in their browser.

So Inka is easy to run yourself: it is one Node.js process you can scale to zero between editing sessions. To recover, redeploy it; there is nothing to restore. When you run it on your own infrastructure, nothing phones home, no content leaves your network, and you decide when to upgrade. PretaGov also runs Inka as a managed service, on a dedicated instance for each customer.

The iframe ↔ admin bridge

During editing the frontend is loaded inside an iframe owned by Inka's admin UI. The two communicate via postMessage over the iframe boundary:

  • Admin → frontend: form-data updates, selection changes, route changes.
  • Frontend → admin: which block was clicked (selection), which slate node holds the cursor, where blocks live in the rendered DOM, slate transform requests so the admin can compute the new value.

This split lets the frontend stay 100% headless when not in admin (just renders content), while the admin gets full visual editing without the frontend having to know any React, any block-form widgets, or any sidebar UI.

The chrome pattern

Selection outlines, the block toolbar, drag handles, edge handles, the empty-block "+" — none of these are rendered by the frontend. They're rendered in the admin (React) layered above the iframe. The frontend only:

  1. Adds the data attributes that mark editable elements (data-block-uid, data-edit-text, data-edit-link, data-edit-media, data-node-id).
  2. Captures pointer events through invisible elements so the admin's chrome stays interactive.
  3. Reports element rects on demand so the chrome can position itself.

The benefit: a frontend's CSS can never break the editing UI, because the editing UI doesn't live in the frontend. Switching frontends mid-edit (Nuxt → Next → Astro) works because the bridge protocol is the same — only the rendered DOM changes. Server-only frameworks without client-side reactivity (Astro, PHP, Django, Rails) participate via the server-render pattern — same bridge protocol, plus a small HTTP endpoint the bridge POSTs to.

Slate (rich text) transforms

When the editor types in a slate field, the frontend doesn't compute the new slate value itself — the admin does, by running the slate transform against the previous slate value. The frontend's job is to:

  1. Receive the new slate value via SLATE_TRANSFORM_RESULT and re-render.
  2. Send slate node data-node-id attributes back so the admin can place the cursor at the right node after re-render.

This is why every slate node needs a data-node-id attribute on its rendered HTML — without one, the admin can't track the cursor across re-renders. See Visual Editing › Renderer Node-ID Rules.

Building a frontend

The steps for creating an Inka-compatible frontend are the same across frameworks: catch-all route → fetch the page from your CMS's API → render blocks recursively → add data-block-uid and data-edit-* attributes on editable elements → load hydra.js only inside the admin iframe.

See Build a frontend for the full step-by-step guide, or the example frontends: Nuxt.js, Next.js, F7-Vue.

Layers of adoption

Inka is additive: each layer below works on its own, and each next row enhances editing without breaking what came before. You can ship at any row, mix rows on the same site, and add the next layer when you're ready.

Step

What you wire up

What editors get

Plain headless

Frontend fetches your CMS's API. No hydra.js involved.

Sidebar editing in Inka, frontend reloads on save. Editors flip between the Inka edit tab and a frontend tab to see results — works fine, but loses inline editing and realtime preview.

Bridge installed

Load hydra.js, call initBridge({ page: { schema: { properties: { ... } } } }). See Live Preview › Setting Up the Bridge.

Frontend follows admin navigation (and vice versa). Frontend renders private content via shared auth (Authentication). Page metadata (title, description, etc.) editable from the admin.

Custom block types

Add a blocks: { ... } config to initBridge. See Custom Blocks › `initBridge()` Reference.

Editors can add, configure, and convert your custom block types — schema renders in the sidebar with no editor plugin to deploy. Cross-block conversion (fieldMappings) becomes possible.

Block selection in preview

Add data-block-uid to your rendered blocks. See Visual Editing › HTML Annotations.

Click-to-select on the preview. Block toolbar above selected blocks. Sidebar↔preview selection scrolls into view. Multi-select with Shift/Ctrl-click.

Realtime preview

Register onEditChange and render from the formData it gives you instead of from the API.

Preview updates as the editor types. Drag-and-drop, slash menu, container ops (wrap, unwrap, edge-drag, convert) all unlock — see the Editor Guide.

Direct field editing

Add data-edit-text, data-edit-link, data-edit-media to specific elements.

Click rendered text and start typing. Click an image to pick or upload. Click a link to open the link picker. Markdown shortcuts (##, **bold**, etc.).

Templates and layouts

Configure allowedTemplates / allowedLayouts on a region; use expandTemplates at render time. See Templates.

Editors pick layouts from a dropdown, insert template snippets via the BlockChooser, recognise locked vs editable vs slot blocks.

Listings and dynamic content

Configure listing block types and pass fetchItems to expandListingBlocks. See Listings.

A fieldMapping widget on listing blocks maps query results to item fields. Listings render as repeated blocks, editable per item.

Custom UI / advanced

Override the editor's components through Volto's add-on system. See Advanced.

Bespoke widgets and custom block edit forms.

Different parts of the same site can sit at different rows — inline-editable headlines on a marketing page, sidebar-only editing on a complex catalog page.

Inside the editor (for contributors)

This section is for people working on Inka itself. You don't need it to build a front end. It describes how the editor, which builds on Volto (Plone's React editor), handles template membership and URLs.

Merging templates: every container is expanded, by whoever renders

A template can be placed anywhere a block can: at the top of a page, in a page's footer region, or inside any container — a grid, a column, an accordion panel, a slider. Nobody knows in advance where an editor will put one. So wherever blocks are rendered, every container's children are expanded.

The merge does not know your containers. expandTemplates / expandTemplatesSync have no block schema. When they apply a template they fill that template's own regions, at every depth. But a template placed inside a plain container — one that is not part of a template being applied — is left exactly as stored until that container's children are themselves passed through the merge.

So the renderer re-enters every container. That is the frontend contract (see Templates): each container passes its children — every blocks_layout region and every object_list field — through the merge, sharing one templateState for the whole render. Content this render already expanded is recognised by its instance id and passed through, so re-entering a template's own regions does not apply it twice.

The admin is a renderer too, and keeps the same contract. It merges templates itself, because it edits them: the page it hands the iframe, the blocks you unlock, and what "Change on all pages" saves all come from its own merge. mergeTemplatesIntoPage runs the merge on each page blocks field and then re-enters every container of the result, at every depth (expandContainers, reading regions through the helpers' getChildFields / getChildBlockEntries / setChildBlockEntries). Without that, a template inside a container is never merged in the editor: it shows its stale stored copy, and unlocking, committing and resetting cannot find it.

This re-entry was removed once (2026-07, "use only the public merge — remove redundant re-entry") on the belief that the merge already fills every nested region. It fills only the regions of a template it applies. Do not remove the admin's re-entry, and do not ship a frontend container that renders its children without passing them through the merge. The tests that guard this place the same template at the top level and inside a container (template-edit-mode.spec.ts, "wherever the instance sits").

Template membership (edit-side slot assignment)

A block's template membership — templateId, templateInstanceId, slotId, fixed, readOnly — is not intrinsic to the block; the admin assigns it at edit time. The merge reads these fields to place content at render time.

One rule, one implementation. Every way an author places a block — adding it, pasting it, moving it (drag and drop, cut and paste) — gives it its membership the same way: applyMembershipWhereLanded in blockSync.js, reached through settleBlockStructure after the block is inserted. The insert itself (insertBlockInContainer) only inserts. Change the rule there and here together.

  1. It keeps its membership when it is a template's own fixed block staying in its template, or when it moves inside the template being edited. In template edit mode the slotId is explicit — there's a slotId field, and the author renames slots rather than changing them by dragging — so a move that stays inside the template keeps its slotId (save/lock validation, not the drag, is what refuses an invalid arrangement). Fixed template blocks are only movable in template edit mode, and their slot and fixed are the template.
  2. Otherwise where it came from is discarded. A block from another template — the page's layout, another snippet — lands exactly as a block with no template at all would. A paste is a new block, so it never keeps a template block's identity.
  3. The add rules for where it landed decide (getTemplateInfoFromNeighbors, via applyBlockDefaultsWithContext). They inspect the immediate neighbours and ask whether a slot faces the gap, in this order:
    • a non-fixed slot neighbour on either side → join its slotId;
    • a fixed anchor whose slot region faces the gap — the block before the gap via its nextSlotId (a trailing slot), or the block after it via its prevSlotId (a leading slot). The merge stamps nextSlotId/prevSlotId on a fixed block when the template has a slot region beside it, so an empty slot can still be joined;
    • otherwise, if the container itself is a template instance (e.g. a columns block carrying a templateId), the block joins that instance with a freshly generated slotId — the only time a slot is invented. The author can rename it.
    If none apply, the position is outside every template — plain page content. A block joining a slot is a slot member, never fixed. Inside a template container it is template content: fixed and readOnly like the block it lands beside, else like the container.
  4. Its contents go with it. Every descendant takes the block's membership and its slotId — every block of a template has a slot, nested ones included (Templates). The exception is a nested template instance of its own (a snippet placed inside the block): its blocks keep their own template, instance and slots. Descendants are walked through the container API, so blocks_layout and object_list regions alike.

The caller passes the block's membership as it was before the insert (sourceById): that is how a placed block's own children are told apart from a nested snippet, and how rule 1 knows where the block came from. A paste passes the instance it was copied from (never fixed); an add passes nothing.

Three details make it hold:

  • Exclude the block from its own neighbour scan. Once inserted, the block sits in the layout at its new index, so a naïve getNeighborData(position) returns the block itself and offers its own stale slot back to it. The recompute filters it out and treats position as the gap between its real neighbours (also the basis of the "inside the template?" test).
  • Write back against the original. The update guard compares the result to the stored block, not the stripped copy, or a block whose recompute is a structural no-op keeps its stale membership.
  • templateEditModeRef for the mode check. The current set of unlocked instances is read from a ref, not an effect-closure value, so the gate sees the live edit-mode state.

Seeding is not placement. Blocks the editor creates on its own — an empty placeholder for an empty slot, a forced region's default empty, a new container's default children — take their slot from what they fill (inheritTemplateMembership in blockPath.js), not from this rule.

In normal mode the net effect matches the merge's own placement rules (a top/bottom slot outside a fixed anchor): dropping a block past a free edge flows it into that slot; dropping it past a both-anchored edge exits it to the surrounding page region. The drag scan that decides the drop position lives in hydra.js and, on a distance tie between coincident edges, prefers the deeper (inner) edge so a reorder inside a container isn't ejected to the outer level.

URL flattening and publicURL

Volto's stock URL helpers (flattenToAppURL, isInternalURL, toPublicURL) assume there's one "public URL" — usually the same origin the admin runs on, configured via RAZZLE_PUBLIC_URL. In Inka the admin and the published frontend(s) live on different origins, and the editor switches between published frontends at will, so there is no single public URL.

Do not set `RAZZLE_PUBLIC_URL` in an Inka deployment. Pinning settings.publicURL to one value would break flattening for every other frontend — pastes from them would be misrecognised as external and saved verbatim instead of as /path references.

Inka makes settings.publicURL follow the currently active iframe frontend:

  • Boot — applyConfig reads the iframe_url_<port> cookie (set by View.jsx on previous visits), looks up the matching saved-frontends entry, and writes settings.publicURL = entry.publishUrl || entry.url. A returning editor sees the right value before they open the switcher.
  • Switch — when the editor picks a different frontend in the toolbar switcher (FrontendSwitcherPanel), it dispatches setFrontendPreviewUrl(url). Inka's publicUrlSync Redux middleware intercepts the action and updates settings.publicURL before the next render.
  • Other frontends — flattenToAppURL and isInternalURL are shadowed to strip publicURL (the active frontend) plus every other saved frontend's edit / publish URL, so a paste from a frontend you're not currently viewing still flattens cleanly.

Saved frontends come from two sources, merged: the RAZZLE_DEFAULT_IFRAME_URL env (baseline list shipped with the deployment, format Name|EditURL[|PublishURL],…) and the saved_urls_<port> cookie (per-editor additions made via the toolbar Settings modal). The optional third slot in each entry is for setups where the published site lives at a different origin than the edit-mode frontend (e.g. edit.example.com for previews, www.example.com for production).

An editor's iframe_url_<port> cookie can name an address that has since become a frontend's publish URL: the frontend used to be framed there, and now has an editing build at an address of its own. A publish URL is a site the editor cannot edit through, so the admin opens that frontend's edit URL instead, and the cookie remembers the edit URL from then on (utils/getRememberedFrontendUrl.js). Any other remembered URL is kept, because an editor can add any URL in the switcher.

What we deliberately did NOT shadow: UniversalLink's fallback href when an item is empty, Volto's admin-side Robots.txt / Sitemap.xml generators, ContentMetadataTags / AlternateHrefLangs in the admin's <head>, and the RegistryImageWidget site-logo URL. All of these inherit the dynamic publicURL transparently, and in an Inka deployment the authoritative robots.txt / sitemap.xml / SEO tags are served by the frontends, not the admin.