Customize blocks
Blocks are what make a Theme composable. An editor drags a block onto a page in Cockpit, sets its props, and the Theme renders it. This tutorial shows how to write a block, register it so editors can use it, and connect a block to live catalog data without slowing down the first paint.
This page is for frontend developers comfortable with Vue 3 <script setup>. You should have a Theme scaffolded already — see Build your first Theme.
How a block works
A Blökkli block is a Vue component with one extra call: defineBlokkli. That call declares the block's bundle (the id that ties the component to the manifest) and, optionally, the mock props the editor shows while composing. The component's props are the fields an editor fills in on the canvas.
Blocks live under app/components/blokkli/<block>/index.vue. A Theme keeps a small map from block id to component so the renderer can resolve a page's block tree to real components.
1. Write a block component
Start with a self-contained block — a hero with a headline, an optional subline, and a background choice. The editor sees the props as editable fields.
<script setup lang="ts">
const props = withDefaults(defineProps<{
headline: string
subheadline?: string
bgPreset?: string
bgCustom?: string
}>(), {
subheadline: undefined,
bgPreset: 'auto',
bgCustom: '#4f46e5',
})
defineBlokkli({
bundle: 'hero',
editor: {
// What the editor previews before real content is entered.
mockProps: () => ({
headline: 'New hero headline',
subheadline: 'New hero subheadline',
}),
},
})
const PRESET_HEX: Record<string, string> = {
white: '#ffffff',
sand: '#f5efe6',
slate: '#1f2937',
mint: '#a7f3d0',
}
const bgHex = computed<string | null>(() => {
if (props.bgPreset === 'custom') return props.bgCustom ?? null
if (props.bgPreset in PRESET_HEX) return PRESET_HEX[props.bgPreset] ?? null
return null
})
</script>
<template>
<section
class="hero"
:style="bgHex ? { backgroundColor: bgHex } : undefined"
>
<h1>{{ props.headline }}</h1>
<p v-if="props.subheadline" class="subline">{{ props.subheadline }}</p>
</section>
</template>
bundle: 'hero' is the link between this component and the hero entry in your manifest. The mockProps give the editor something to show before real content exists.
2. Register the block
Two registrations make the block usable.
First, declare it in theme.json so it appears in the editor's block list:
{
"blokkli": {
"blocks": [
{ "id": "hero", "title": "Hero", "description": "Full-width hero with headline, subline, and a background choice." }
]
}
}
Second, map the block id to its component so the renderer can resolve it:
import type { Component } from 'vue'
import BlokkliHero from '../components/blokkli/hero/index.vue'
import BlokkliProductGrid from '../components/blokkli/product_grid/index.vue'
import BlokkliText from '../components/blokkli/text/index.vue'
export const BLOCK_COMPONENTS: Record<string, Component> = {
hero: BlokkliHero,
product_grid: BlokkliProductGrid,
text: BlokkliText,
}
Keep the manifest id, the component's bundle, and the map key in sync. Drift between them is the most common reason a block renders as "unknown".
3. Connect a block to live catalog data
A product block needs data from the platform. The catalog (names, images, descriptions, categories) is cheap and cacheable; price and availability are buyer-specific and slow. The pattern that keeps storefronts fast is shell-first: render the catalog during server-side rendering, then fill in price and stock after the page paints.
Fetch the catalog in the block
A product block reads its category from the editor's props and fetches the matching products. The Theme exposes a useProducts composable that calls the Theme's server-side API route, which in turn calls the gateway with the per-request tenant context.
<script setup lang="ts">
const props = withDefaults(defineProps<{
categorySlug?: string
limit?: string
sort?: 'featured' | 'price-asc' | 'price-desc' | 'name-asc'
}>(), {
categorySlug: '',
limit: '8',
sort: 'featured',
})
defineBlokkli({
bundle: 'product_grid',
editor: { mockProps: () => ({}) },
})
const category = computed(() => props.categorySlug?.trim() || undefined)
// Catalog shell — fetched during SSR, in the first byte. No price/stock here.
const { products, status, error } = useProducts(category)
const visible = computed(() => {
const n = Number(props.limit) || 0
return n > 0 ? products.value.slice(0, n) : products.value
})
</script>
<template>
<section class="product-grid">
<CategoryListSkeleton v-if="status === 'pending'" />
<ErrorBoundary v-else-if="error" title="Products could not be loaded" />
<EmptyState v-else-if="!visible.length" title="No products found" />
<div v-else class="grid">
<ProductCard v-for="product in visible" :key="product.id" :product="product" />
</div>
</section>
</template>
The catalog response carries no prices. That's deliberate — it keeps the catalog cacheable and stops a slow price lookup from blocking the grid.
Defer the offer
Price and availability are the offer. Resolve them on their own axis: one batched request for the visible products, fired after the page paints, with a skeleton until it returns. A shared useOffers composable does the batching and caching.
export interface Offer {
id: string
prices: { [tier: string]: number }[]
isOutOfStock: boolean
}
export function useOffers() {
const cache = useState<Record<string, Offer>>('offers:cache', () => ({}))
const inflight = useState<Record<string, boolean>>('offers:pending', () => ({}))
// Ids whose fetch has finished (resolved or soft-failed). Until then a product
// reads as "pending" so SSR and the first client render both show a skeleton.
const attempted = useState<Record<string, boolean>>('offers:attempted', () => ({}))
async function loadOffers(items: { id: string; sku?: string }[]): Promise<void> {
// No-op on the server: the offer never blocks the first byte.
if (import.meta.server) return
const want = items.filter(
item => item?.id && !(item.id in cache.value) && !inflight.value[item.id],
)
if (!want.length) return
inflight.value = { ...inflight.value, ...Object.fromEntries(want.map(i => [i.id, true])) }
try {
const { offers } = await $fetch<{ offers: Record<string, Offer> }>('/api/offers', {
method: 'POST',
body: { items: want.map(i => ({ id: i.id, sku: i.sku })) },
})
cache.value = { ...cache.value, ...offers }
}
catch {
// Soft failure: leave uncached so widgets render "price on request", never €0.
}
finally {
const next = { ...inflight.value }
for (const i of want) delete next[i.id]
inflight.value = next
attempted.value = { ...attempted.value, ...Object.fromEntries(want.map(i => [i.id, true])) }
}
}
function useOffer(getId: MaybeRefOrGetter<string>) {
const id = computed(() => toValue(getId))
const offer = computed<Offer | undefined>(() => cache.value[id.value])
// Default-pending so SSR and the first client render agree (no hydration flash).
const pending = computed(() => !offer.value && !attempted.value[id.value])
return { offer, pending }
}
return { loadOffers, useOffer }
}
Trigger one loadOffers for the visible products — once per page, never one request per card:
<script setup lang="ts">
const { loadOffers } = useOffers()
// Batch the visible products into a single offer request after they're known.
watch(visible, (list) => {
if (list.length) void loadOffers(list)
}, { immediate: true })
</script>
The price widget on each card reads the shared cache and shows a skeleton while pending:
<script setup lang="ts">
const props = defineProps<{ productId: string }>()
const { useOffer } = useOffers()
const { offer, pending } = useOffer(() => props.productId)
</script>
<template>
<span v-if="pending" class="price-skeleton" aria-hidden="true" />
<span v-else-if="offer">{{ formatPrice(offer.prices) }}</span>
<span v-else class="price-on-request">Price on request</span>
</template>
The skeleton is the default state, not the empty state — that way the server and the first client render produce the same markup, so there's no hydration mismatch and no flash of "price on request".
Rules that keep blocks fast
- Catalog in SSR, offer deferred. Never fetch price or stock inside the catalog response just because it's convenient — it re-welds the boundary that keeps the catalog cacheable.
- One offer request per page. Batch the visible items; don't fire one request per card.
- Skeleton by default. A deferred value is unknown during SSR. Render the skeleton, not the empty state, so SSR and the first client render agree.
- Soft-fail resolves the skeleton. A failed offer fetch should fall back to "price on request", never spin forever and never render €0.
- Read the tenant per request. Server-side routes use the per-request tenant context. Never cache one tenant's catalog under a key that another tenant can read.
Troubleshooting
- Block renders as "unknown". The manifest
id, the component'sbundle, and the key in your block map don't match. Align all three. - Prices flash on load. Your price widget defaults to the empty state instead of the skeleton, so the client render differs from SSR. Make the skeleton the default while
pending. - The grid waits on prices. The catalog fetch is pulling price data. Split it: catalog in SSR, offer deferred via
useOffers.
What's next
- Theme tokens and styling — brand your blocks per tenant with CSS custom properties.
- Deploy your storefront — ship the Theme with its new blocks.
- Reference: Blökkli blocks.