Writing the component
This page walks through every part of a section's index.vue, using the real Heading section as the template. Open components/sections/Heading/index.vue alongside this guide.
The skeleton
Every section has the same shape:
<script setup lang="ts">
import { computed } from 'vue';
import { useT } from '~/composables/useT';
import { useEditable } from '~/composables/useEditable';
import { useStyles, type ElementStylePayload } from '~/composables/useStyles';
// 1. Type the config object the merchant edits.
interface MyConfig {
text?: string | Record<string, string | undefined>; // translatable
styles?: Record<string, ElementStylePayload>;
}
const props = defineProps<{ config: MyConfig }>();
// 2. Composables.
const t = useT(); // i18n
const sectionStyles = useStyles('section', () => props.config?.styles, ['self']);
const textEdit = useEditable('text', { translatable: true });
// 3. Computed presentation values.
const text = computed(() => t(props.config?.text) || 'Heading');
</script>
<template>
<div
:class="sectionStyles.attrs.value.class"
:data-element-key="sectionStyles.attrs.value['data-element-key']"
>
<h2 v-bind="textEdit.editableProps()">{{ text }}</h2>
</div>
</template>That is a complete, builder-aware section. Three composables do most of the work.
useT() — translatable text
Merchant-editable text can be either a plain string or a per-locale map:
"title": "Summer sale"
"title": { "en": "Summer sale", "fr": "Soldes d'été", "ar": "تخفيضات الصيف" }You don't pick which one — the merchant decides per field, depending on whether they enable translation. useT() handles both:
const t = useT();
const text = computed(() => t(props.config?.title) || 'Default');t(value) returns the right string for the active language, falling back gracefully. Always provide a default — props.config?.title may be undefined on a newly-inserted instance before the merchant types anything.
Rule of thumb: any field you marked
"translatable": truein the manifest must be read throughuseT()in the component. Forgetting this is the single most common new-section bug.
useStyles() — merchant-editable styles
Merchants change colors, sizes, backgrounds, paddings through the Style panel in the builder. useStyles is how your component picks those edits up.
Basic call signature:
const titleStyles = useStyles(
'title', // key — must match a styleKeys entry in manifest.json
() => props.config?.styles, // accessor — pinia-tracked
['self'], // scope (optional) — see below
);It returns { attrs } — a reactive object you spread onto your element:
<h2
:class="titleStyles.attrs.value.class"
:style="titleStyles.attrs.value.style"
:data-element-key="titleStyles.attrs.value['data-element-key']"
>The data-element-key attribute is what the builder's UI uses to highlight the element when the merchant hovers over its row in the Style panel.
Scope: 'self' vs descendant selector
useStyles(key, acc, ['self'])— applies to the element you spreadattrsonto. Use this for unique elements (the section wrapper, the single hero title).useStyles(key, acc, [], '.product-card')— applies via descendant CSS selector. Use this for repeating elements (every card in a grid, every link in a list). The CSS will apply to all matching descendants.
Look at ProductGrid/index.vue for descendant-selector patterns.
useEditable() — inline text editing
When the merchant is in builder mode and clicks directly on a heading inside the iframe, they should be able to type a new value in place. useEditable does this.
const textEdit = useEditable('text', { translatable: true });The first argument is the manifest field path — text corresponds to the field with "path": "text" in your manifest's schema. The composable returns a function you v-bind onto the editable element:
<span v-bind="textEdit.editableProps()">{{ text }}</span>That's it. The composable handles the contenteditable, blur-to-save, keyboard shortcuts, and the postMessage handshake back to the builder.
Use it for visible text fields the merchant will want to edit as they see it. Skip it for fields edited via dropdowns / pickers / images (those live in the right-hand inspector instead).
Defending against missing fields
The merchant might insert your section and leave fields empty. The component must render anyway. Always:
- Use optional chaining:
props.config?.image?.src. - Provide defaults at read time:ts
const layout = computed<Layout>(() => props.config?.layout || 'image-bg'); - Make types optional in the interface:ts
interface BannerConfig { image?: { src?: string; alt?: string }; ... }
The manifest's "defaults" field is the merchant's starting state, but a config field can still be cleared. Never assume.
A note on SSR
Souk-theme is SSR'd. Your component renders both on the Nuxt server (initial HTML) and in the browser (hydration). Things to know:
- No
documentorwindowaccess at top level. If you need them, gate behindif (typeof window !== 'undefined')or useonMounted. - Builder-specific hooks are client-only.
useEditablealready handles this; its effects only fire in the browser. - Avoid timing-based logic at SSR.
Date.now(),Math.random()produce hydration mismatches if SSR and CSR disagree.
If you need an iframe-only feature (canvas, video player, drag-drop), use <ClientOnly>:
<ClientOnly>
<MyCanvasWidget :data="config.data" />
</ClientOnly>Common patterns
Repeating items (cards, list rows)
For a grid of cards rendered from a config array:
<script setup lang="ts">
const cards = computed(() => props.config?.cards ?? []);
const cardStyles = useStyles('card', () => props.config?.styles, [], '.section-card');
const cardTitleStyles = useStyles('cardTitle', () => props.config?.styles, [], '.section-card__title');
</script>
<template>
<section :class="cardStyles.attrs.value.class">
<div v-for="(card, i) in cards" :key="i" class="section-card">
<h3 class="section-card__title">{{ t(card.title) }}</h3>
</div>
</section>
</template>The descendant scope on cardStyles applies the merchant's edits to every .section-card inside this section.
Mobile fallback for images
Most image fields have a mobile companion. Pattern:
<picture>
<source v-if="config.imageMobile" media="(max-width: 767px)" :srcset="config.imageMobile.src" />
<img :src="config.image?.src" :alt="config.image?.alt || ''" />
</picture>Translatable arrays
If a field is "translatable": true and a list (bullets: [{ text: ... }]), each item's text is independently translatable. Map and t() per item.
What's next
Open Writing the manifest to learn the schema field types (text, image, select, picker, ...) you can offer the merchant.