Shells and fields
A container block — a section, a row, a column, a page shell — holds other blocks. Declaring one takes two things: a propsFieldMapping entry that tells the editor a prop is a drop target rather than a value, and two render paths, because the editor and the live page nest children differently.
propsFieldMapping
By default every prop is a plain value an editor types in. propsFieldMapping reclassifies one:
defineBlokkli({
bundle: 'content_section',
propsFieldMapping: {
content: { type: 'field', name: 'content' },
},
})
That declares: the content prop is not a string, it is a field — a named list of child blocks the editor can drop into.
The field name, the propsFieldMapping key, and the slot name must all be the same string. In every example on this page that string is content, in all three places. A mismatch is the usual reason a container renders empty in the editor.
Two render paths
The editor needs a real drop target. The live page should not load any editor code at all. So a container branches:
<script setup lang="ts">
import { INJECT_IS_PREVIEW } from '#blokkli/helpers/injections'
defineBlokkli({
bundle: 'content_section',
propsFieldMapping: {
content: { type: 'field', name: 'content' },
},
})
// In the editor and in preview, children must render through <BlokkliField>.
// On a live page they arrive as a named slot instead.
const isEditing = import.meta.blokkliEditing
const isPreview = inject(INJECT_IS_PREVIEW, null)
const renderField = computed(() => isEditing || !!isPreview?.value)
</script>
<template>
<section>
<BlokkliField v-if="renderField" name="content" />
<slot v-else name="content" />
</section>
</template>
Both halves of the condition matter. import.meta.blokkliEditing is true in the editor's edit chunk. INJECT_IS_PREVIEW is provided when the block renders under a preview provider — the editor's responsive preview, and the shareable preview link. Miss the second and your container looks right while editing and empty when someone previews the page.
BlokkliField takes name as its only required prop. listClass, editClass, and dropAlignment are the ones you'll most often add to make the drop target behave — for instance a grid container wants its listClass to be the grid, so dropped blocks land in cells rather than in a column.
The shell-plus-widgets pattern
The reference theme's functional pages — cart, checkout, product detail, the account area, listings — are not one monolithic block each. They're a shell that hosts a field of widgets.
cart ← the shell: one block, locked in the editor
└─ content (field)
├─ cart_header
├─ cart_messages
├─ cart_positions
├─ cart_summary
└─ cart_recommendations
The shell owns the page-level concerns: the layout, the orchestration, the modals, the one call that recalculates the cart. The widgets own their own slice of the screen. Which means an editor can reorder the cart, drop the recommendations below the summary, or remove the messages panel entirely — without a developer touching the theme.
Three conventions make the pattern work:
The shell is locked. An editor can rearrange a shell's widgets but not delete the shell, because deleting it would break the page's function. Use editor.maxInstances and the editor's locking affordances rather than hoping.
Widgets read shared state, not props from the shell. Cart state lives in a store and in useState-backed composables, so cart_positions reads the cart directly. The shell needs no provide/inject and the widgets can be reordered freely, because none of them depends on being a particular child of a particular parent.
The shell is the single orchestrator. One block triggers the debounced recalculation; the widgets only read the result. If every widget recalculated, a cart page would fire five identical requests.
Keep the add-list honest. Widget bundles are only meaningful inside their shell, so the editor's add-list is kept context-sensitive: at the page root only standalone and shell bundles are offered, and a shell's widgets appear when the shell is selected. Without that, an editor's block picker fills up with ninety entries, most of which do nothing where they'd be dropped.
Seeding a shell in the editor
A functional shell often has nothing to render in the editor — there is no real cart in an editing session, so a cart page would look empty and unreviewable. Seed deterministic sample state for the editor only:
const isEditing = import.meta.blokkliEditing
onMounted(() => {
if (!isEditing || !cart.isEmpty) return
cart.addItem({ id: 'sample-1', name: 'Sample item A', sku: 'SAMPLE-A', price: 19.9 })
cart.addItem({ id: 'sample-2', name: 'Sample item B', sku: 'SAMPLE-B', price: 49 })
})
Guard it on isEditing and on the state actually being empty. Sample data that reaches a live page — or that overwrites a real shopper's cart — is a much worse bug than an empty editor canvas.
Nesting depth
Containers nest: a content_section can hold a row, which holds columns, which hold ordinary blocks. Each level needs its own propsFieldMapping entry and its own two render paths.
Deep nesting is where field-name mismatches bite, because the symptom appears one level away from the cause. When a nested container comes up empty, check the three matching strings at that level before looking anywhere else.
Next steps
- How a block works — the
defineBlokklikeys. - Block options — the knobs, including the shared
colSpanwidth used by column layouts. - Block catalog — every shell and its widgets, grouped.
- Reading published pages — how the block tree reaches your renderer.