Create a New Block in Front-Office
A block is a reusable Vue component rendered dynamically in page zones. Each block receives its configuration from the API via block.config, supports localization and theming, and is resolved at runtime through a component registry.
Data Flow
Backend API → usePages() composable → BlockZones (structure)
→ DynamicZone (component) → Registry resolution → Block Component
Block Zones
Blocks can be placed in 5 zones on a page:
| Zone | Purpose |
|---|
before_content | Banners, hero sections |
content | Main page content |
after_content | Newsletter, CTAs |
sidebar | Left sidebar |
right_sidebar | Right sidebar |
File Structure
cms-fo/
├── types/blocks.ts # Block type definitions
├── data/block-metadata.ts # Block metadata (names, categories)
├── components/dynamic-blocks/
│ ├── registry.ts # Type → component mapping
│ ├── YourNewBlock.vue # Your block component
│ └── UnknownBlock.vue # Fallback for unrecognized types
├── utils/blockStyles.ts # Style utilities
└── utils/blocks-normalizer.ts # Data normalization
Step-by-Step Implementation
1. Define the Block Type
In types/blocks.ts, add your block type to the union:
export type BlockType = 'hero' | 'cta' | 'your_new_block'
In data/block-metadata.ts:
your_new_block: {
name: { fr: 'Nouveau bloc', en: 'New Block' },
description: { fr: 'Description du bloc', en: 'Block description' },
category: 'content',
allowedOptions: ['variant', 'title', 'content']
}
3. Create the Component
Create components/dynamic-blocks/YourNewBlock.vue:
<script setup lang="ts">
import { computed } from 'vue'
import type { BlockPayload, SupportedLocale } from '~/types/blocks'
import { getLocalizedString, sanitizeHtml } from '~/utils/blocks-normalizer'
interface YourBlockConfig {
title?: Record<string, string>
content?: Record<string, string>
image?: string
variant?: string
}
const props = defineProps<{
block: BlockPayload
locale: SupportedLocale
context?: { type: 'article' | 'page'; slug: string }
zone?: string
}>()
const config = computed(() => props.block.config as YourBlockConfig)
const title = computed(() => getLocalizedString(config.value.title, props.locale))
const content = computed(() => sanitizeHtml(
getLocalizedString(config.value.content, props.locale)
))
const { template } = useTheme()
</script>
<template>
<!-- Nordic Ledger theme -->
<section v-if="template === 'nordicLedger'" class="nordic-variant">
<h2>{{ title }}</h2>
<div v-html="content" />
</section>
<!-- Aurora Pulse theme -->
<section v-else-if="template === 'auroraPulse'" class="aurora-variant">
<h2>{{ title }}</h2>
<div v-html="content" />
</section>
<!-- Default theme -->
<section v-else class="default-variant">
<h2>{{ title }}</h2>
<div v-html="content" />
</section>
</template>
4. Register in the Registry
In components/dynamic-blocks/registry.ts:
your_new_block: defineAsyncComponent(
() => import('~/components/dynamic-blocks/YourNewBlock.vue')
)
BlockPayload Structure
Every block component receives this data structure:
interface BlockPayload {
id: number // Unique ID
identifier: string // Slug-like identifier
type: BlockType | string // Block type key
name?: Record<string, string | null> // Localized display name
description?: Record<string, string | null>
config: Record<string, unknown> // Free-form configuration (main data)
position: number // Order in zone (0 = first)
}
config is a free-form object where the backend places all business data. Your component is responsible for casting and validating it.
Available Utilities
| Utility | Purpose |
|---|
getLocalizedString(field, locale) | Extract localized string from Record<string, string> |
sanitizeHtml(html) | Sanitize HTML to prevent XSS — always use before v-html |
resolveBlockImage(path) | Resolve image URLs (handles relative/absolute) |
useTheme() | Access active theme (default, nordicLedger, auroraPulse, canvasMosaic) |
Theme Variants
The CMS supports 4 themes. Complex blocks should implement visual variants for each:
| Theme | Style |
|---|
default | Classic CMS style |
nordicLedger | Editorial / analytical |
auroraPulse | Modern / immersive |
canvasMosaic | Brutalist / artistic |
Use const { template } = useTheme() to detect the active theme and render the appropriate variant.
Simple blocks that only use atom components (Text, Button, Image) don’t need explicit theme variants — the atoms handle theming automatically.
Practical Examples
Simple Text Block
<script setup lang="ts">
const props = defineProps<{ block: BlockPayload; locale: SupportedLocale }>()
const config = computed(() => props.block.config as { title?: Record<string, string>; body?: Record<string, string> })
const title = computed(() => getLocalizedString(config.value.title, props.locale))
const body = computed(() => sanitizeHtml(getLocalizedString(config.value.body, props.locale)))
</script>
<template>
<div class="text-block">
<h2 v-if="title">{{ title }}</h2>
<div v-if="body" v-html="body" />
</div>
</template>
Image Block
<script setup lang="ts">
const props = defineProps<{ block: BlockPayload; locale: SupportedLocale }>()
const config = computed(() => props.block.config as { src?: string; alt?: Record<string, string>; caption?: Record<string, string> })
const alt = computed(() => getLocalizedString(config.value.alt, props.locale))
const caption = computed(() => getLocalizedString(config.value.caption, props.locale))
</script>
<template>
<figure>
<img v-if="config.src" :src="resolveBlockImage(config.src)" :alt="alt" loading="lazy" />
<figcaption v-if="caption">{{ caption }}</figcaption>
</figure>
</template>
Interactive Block (Client-Only)
<template>
<ClientOnly>
<div class="interactive-block">
<!-- Hydration-safe interactive content -->
</div>
</ClientOnly>
</template>
Best Practices
- Implement all 4 theme variants for complex blocks with distinct layouts
- Use atom components (Text, Button, Image, Container, Card) for consistency
- Check optional values with
v-if before rendering
- Always sanitize HTML with
sanitizeHtml() before v-html
- Use semantic HTML and add ARIA attributes for accessibility
- Lazy-load heavy components and avoid expensive computations in
setup()
- Always localize text with
getLocalizedString()
Testing
Write unit tests with Vitest and Vue Test Utils:
import { mount } from '@vue/test-utils'
import YourNewBlock from './YourNewBlock.vue'
describe('YourNewBlock', () => {
it('renders title from config', () => {
const wrapper = mount(YourNewBlock, {
props: {
block: {
id: 1, identifier: 'test', type: 'your_new_block',
config: { title: { en: 'Hello', fr: 'Bonjour' } },
position: 0
},
locale: 'en'
}
})
expect(wrapper.text()).toContain('Hello')
})
})
Validation Checklist
Before submitting your block for review: