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:
ZonePurpose
before_contentBanners, hero sections
contentMain page content
after_contentNewsletter, CTAs
sidebarLeft sidebar
right_sidebarRight 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'

2. Add Metadata

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

UtilityPurpose
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:
ThemeStyle
defaultClassic CMS style
nordicLedgerEditorial / analytical
auroraPulseModern / immersive
canvasMosaicBrutalist / 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

  1. Implement all 4 theme variants for complex blocks with distinct layouts
  2. Use atom components (Text, Button, Image, Container, Card) for consistency
  3. Check optional values with v-if before rendering
  4. Always sanitize HTML with sanitizeHtml() before v-html
  5. Use semantic HTML and add ARIA attributes for accessibility
  6. Lazy-load heavy components and avoid expensive computations in setup()
  7. 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:
  • Type added to blocks.ts
  • Metadata defined in block-metadata.ts
  • Component created in dynamic-blocks/
  • Component registered in registry.ts
  • All 4 theme variants implemented (if complex block)
  • Texts localized with getLocalizedString()
  • HTML sanitized with sanitizeHtml()
  • Optional values guarded with v-if
  • Atom components used where appropriate
  • Unit tests written
  • Works in all 5 zones
  • Accessible (keyboard navigation, ARIA attributes)