Créer un bloc Gutenberg FAQ avec schema SEO
Mis à jour en 2026
Créer une FAQ dans WordPress n’est pas compliqué. Créer un bloc Gutenberg FAQ propre, maintenable et cohérent côté SEO est une autre histoire. En pratique, beaucoup de projets empilent un plugin FAQ, un plugin SEO et parfois un constructeur visuel, puis se retrouvent avec un HTML peu propre, un JSON-LD approximatif et une interface d’édition pénible pour les équipes contenu.
Sur un projet WordPress un peu sérieux, je recommande généralement une approche plus saine : un bloc custom, avec une interface d’édition en JS ou TSX, un rendu dynamique en PHP et une génération du schema FAQPage à partir des mêmes données que celles affichées à l’écran. C’est particulièrement pertinent dans une stack moderne comme Roots, Sage, Acorn ou Radicle.
L’objectif de ce guide n’est pas de “promettre des rich snippets”. Il est plus simple et plus utile : montrer comment intégrer un vrai bloc FAQ maintenable dans WordPress. Si vous travaillez déjà sur ce type d’architecture, c’est le même niveau d’exigence que j’applique dans mes missions de développement WordPress sur mesure.
Pourquoi un bloc FAQ custom est souvent préférable
Un plugin générique peut suffire pour un site simple. Mais dès qu’il y a une charte, un design system, des contraintes d’intégration ou une logique métier un peu spécifique, le bloc sur mesure devient généralement le meilleur choix.
- Vous contrôlez le HTML réellement rendu en front.
- Vous gardez une seule source de vérité pour le contenu visible et le schema.
- Vous réduisez la dépendance à des plugins tiers parfois trop lourds.
- Vous pouvez faire évoluer le markup sans resauvegarder tous les contenus.
Côté SEO, il faut rester prudent. Google documente toujours FAQPage, mais son affichage enrichi n’est plus aussi large qu’avant. En pratique, le balisage reste utile pour structurer proprement le contenu, mais il ne faut pas le présenter comme un raccourci automatique vers plus de visibilité.
Architecture recommandée
Pour un bloc FAQ sérieux, je recommande généralement quatre briques :
block.jsonpour la déclaration du bloc.faq-schema.block.tsxpour l’interface Gutenberg.render.phppour le rendu dynamique.- Des styles séparés pour l’éditeur et le front.
Le point clé est simple : le bloc doit avoir une seule source de vérité. Les questions et réponses doivent alimenter à la fois le HTML visible et le JSON-LD. Si ces deux couches divergent, le bloc est mal conçu.
Arborescence recommandée
inc/
blocks/
faq-schema/
block.json
render.php
resources/
scripts/
editor/
faq-schema.block.tsx
Cette structure fonctionne bien dans un WordPress classique, mais aussi dans un projet Roots. Dans Sage ou Radicle, le fichier TSX peut être intégré proprement à l’entrée éditeur, tandis que le PHP reste responsable du rendu final et du schema.
1. Déclarer le bloc avec block.json
WordPress recommande d’utiliser block.json comme définition canonique d’un bloc. C’est le point d’entrée le plus propre pour les attributs, les supports et les assets.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "ba/faq-schema",
"title": "FAQ Schema",
"category": "text",
"icon": "editor-help",
"description": "Bloc FAQ dynamique avec JSON-LD FAQPage.",
"keywords": ["faq", "schema", "seo", "gutenberg"],
"supports": {
"html": false,
"anchor": true,
"align": ["wide", "full"]
},
"attributes": {
"title": {
"type": "string",
"default": "Questions frequentes"
},
"intro": {
"type": "string",
"default": ""
},
"headingLevel": {
"type": "string",
"default": "h2"
},
"enableSchema": {
"type": "boolean",
"default": true
},
"openFirstItem": {
"type": "boolean",
"default": false
},
"items": {
"type": "array",
"default": []
}
},
"editorScript": "file:./faq-schema.block.js",
"render": "file:./render.php"
}
Le point important ici est de rester sobre. Une FAQ n’a pas besoin de cinquante options pour être utile. Mieux vaut un bloc clair, stable et facile à éditer.
2. Générer le front avec render.php
Pour une FAQ, le rendu dynamique en PHP est généralement le meilleur choix. Il permet de nettoyer les données, d’ignorer les entrées incomplètes et de générer un schema cohérent sans dupliquer la logique côté JS.
<?php
defined('ABSPATH') || exit;
$defaults = [
'title' => 'Questions frequentes',
'intro' => '',
'headingLevel' => 'h2',
'enableSchema' => true,
'openFirstItem' => false,
'items' => [],
];
$attributes = wp_parse_args(is_array($attributes) ? $attributes : [], $defaults);
$allowed_heading_levels = ['h2', 'h3', 'h4', 'h5', 'h6'];
$heading_tag = in_array($attributes['headingLevel'], $allowed_heading_levels, true)
? $attributes['headingLevel']
: 'h2';
$raw_items = is_array($attributes['items']) ? $attributes['items'] : [];
$items = [];
foreach ($raw_items as $item) {
if (! is_array($item)) {
continue;
}
$question = trim((string) ($item['question'] ?? ''));
$answer = trim((string) ($item['answer'] ?? ''));
if ($question === '' || $answer === '') {
continue;
}
$items[] = [
'question' => sanitize_text_field($question),
'answer' => wp_kses_post($answer),
];
}
if ($items === []) {
return '';
}
$wrapper_attributes = get_block_wrapper_attributes([
'class' => 'ba-faq-schema',
]);
$schema = null;
if (! empty($attributes['enableSchema'])) {
$schema = [
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => array_map(static function ($item) {
return [
'@type' => 'Question',
'name' => wp_strip_all_tags($item['question']),
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => wp_strip_all_tags($item['answer']),
],
];
}, $items),
];
}
ob_start();
?>
<section <?php echo $wrapper_attributes; ?>>
<?php if (trim((string) $attributes['title']) !== '') : ?>
<<?php echo esc_html($heading_tag); ?> class="ba-faq-schema__title">
<?php echo esc_html($attributes['title']); ?>
</<?php echo esc_html($heading_tag); ?>>
<?php endif; ?>
<?php if (trim((string) $attributes['intro']) !== '') : ?>
<p class="ba-faq-schema__intro">
<?php echo esc_html($attributes['intro']); ?>
</p>
<?php endif; ?>
<div class="ba-faq-schema__items">
<?php foreach ($items as $index => $item) : ?>
<details
class="ba-faq-schema__item"
<?php echo ! empty($attributes['openFirstItem']) && 0 === $index ? 'open' : ''; ?>
>
<summary class="ba-faq-schema__question">
<?php echo esc_html($item['question']); ?>
</summary>
<div class="ba-faq-schema__answer">
<?php echo wpautop($item['answer']); ?>
</div>
</details>
<?php endforeach; ?>
</div>
<?php if ($schema) : ?>
<script type="application/ld+json">
<?php echo wp_json_encode($schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>
</script>
<?php endif; ?>
</section>
<?php
return (string) ob_get_clean();
Ce rendu couvre déjà l’essentiel : sanitation, filtrage des items vides, HTML visible et JSON-LD synchronisé. En pratique, c’est ce qui manque à beaucoup de blocs FAQ “rapides” construits uniquement côté éditeur.
3. Concevoir l’éditeur avec faq-schema.block.tsx
Le rôle du fichier TSX est clair : fournir une interface simple pour gérer le tableau items, les options du bloc et un aperçu raisonnable dans Gutenberg.
type BlockComponentProps = {children?: JSX.Element | JSX.Element[] | null}
type BlockPropsRecord = Record<string, unknown>
type FaqItem = {
question: string
answer: string
}
type Attributes = {
title: string
intro: string
headingLevel: string
enableSchema: boolean
openFirstItem: boolean
items: FaqItem[]
}
const {wp} = window as Window & typeof globalThis & {
wp: {
blockEditor: {
useBlockProps: (props?: BlockPropsRecord) => BlockPropsRecord
InspectorControls: (props: BlockComponentProps) => JSX.Element
}
components: {
PanelBody: (props: {title: string; initialOpen?: boolean; children?: JSX.Element | JSX.Element[] | null}) => JSX.Element
TextControl: (props: {
label: string
value: string
onChange: (value: string) => void
}) => JSX.Element
TextareaControl: (props: {
label: string
value: string
onChange: (value: string) => void
}) => JSX.Element
ToggleControl: (props: {
label: string
checked: boolean
onChange: (value: boolean) => void
}) => JSX.Element
SelectControl: (props: {
label: string
value: string
options: Array<{label: string; value: string}>
onChange: (value: string) => void
}) => JSX.Element
Button: (props: Record<string, unknown> & {children?: JSX.Element | string | null}) => JSX.Element
}
i18n: {
__: (text: string, domain?: string) => string
}
}
}
const {useBlockProps, InspectorControls} = wp.blockEditor
const {PanelBody, TextControl, TextareaControl, ToggleControl, SelectControl, Button} = wp.components
const {__} = wp.i18n
const defaults: Attributes = {
title: `Questions frequentes`,
intro: ``,
headingLevel: `h2`,
enableSchema: true,
openFirstItem: false,
items: [],
}
const cloneItems = (items: FaqItem[]) => items.map((item) => ({...item}))
const moveItem = (items: FaqItem[], from: number, to: number) => {
const nextItems = cloneItems(items)
if (to < 0 || to >= nextItems.length) {
return nextItems
}
const [movedItem] = nextItems.splice(from, 1)
nextItems.splice(to, 0, movedItem)
return nextItems
}
const Preview = ({attributes}: {attributes: Attributes}) => {
const HeadingTag = attributes.headingLevel as keyof JSX.IntrinsicElements
const items = attributes.items.filter((item) => item.question.trim() && item.answer.trim())
return (
<section className="ba-faq-schema">
{attributes.title ? <HeadingTag className="ba-faq-schema__title">{attributes.title}</HeadingTag> : null}
{attributes.intro ? <p className="ba-faq-schema__intro">{attributes.intro}</p> : null}
<div className="ba-faq-schema__items">
{items.length ? (
items.map((item, index) => (
<details
key={`${item.question}-${index}`}
className="ba-faq-schema__item"
open={attributes.openFirstItem && index === 0}
>
<summary className="ba-faq-schema__question">{item.question}</summary>
<div className="ba-faq-schema__answer">
<p>{item.answer}</p>
</div>
</details>
))
) : (
<p>{__(`Ajoutez au moins une question et une reponse.`, `joule-child`)}</p>
)}
</div>
</section>
)
}
export const name = `ba/faq-schema`
export const title = `FAQ Schema`
export const description = `Bloc FAQ dynamique avec JSON-LD FAQPage.`
export const category = `text`
export const icon = `editor-help`
export const supports = {
html: false,
anchor: true,
align: [`wide`, `full`],
}
export const attributes = {
title: {
type: `string`,
default: defaults.title,
},
intro: {
type: `string`,
default: defaults.intro,
},
headingLevel: {
type: `string`,
default: defaults.headingLevel,
},
enableSchema: {
type: `boolean`,
default: defaults.enableSchema,
},
openFirstItem: {
type: `boolean`,
default: defaults.openFirstItem,
},
items: {
type: `array`,
default: defaults.items,
},
}
export const edit = ({
attributes,
setAttributes,
}: {
attributes: Attributes
setAttributes: (next: Partial<Attributes>) => void
}) => {
const blockProps = useBlockProps({
className: `ba-faq-schema`,
})
const items = Array.isArray(attributes.items) ? attributes.items : []
const updateItem = (index: number, key: keyof FaqItem, value: string) => {
const nextItems = cloneItems(items)
nextItems[index] = {...nextItems[index], [key]: value}
setAttributes({items: nextItems})
}
const addItem = () => {
setAttributes({
items: [...items, {question: ``, answer: ``}],
})
}
const removeItem = (index: number) => {
setAttributes({
items: items.filter((_, itemIndex) => itemIndex !== index),
})
}
const moveUp = (index: number) => {
setAttributes({items: moveItem(items, index, index - 1)})
}
const moveDown = (index: number) => {
setAttributes({items: moveItem(items, index, index + 1)})
}
return (
<>
<InspectorControls>
<PanelBody title={__(`Parametres`, `joule-child`)} initialOpen>
<TextControl
label={__(`Titre`, `joule-child`)}
value={attributes.title}
onChange={(value: string) => setAttributes({title: value})}
/>
<TextareaControl
label={__(`Introduction`, `joule-child`)}
value={attributes.intro}
onChange={(value: string) => setAttributes({intro: value})}
/>
<SelectControl
label={__(`Niveau du titre`, `joule-child`)}
value={attributes.headingLevel}
options={[
{label: `H2`, value: `h2`},
{label: `H3`, value: `h3`},
{label: `H4`, value: `h4`},
{label: `H5`, value: `h5`},
{label: `H6`, value: `h6`},
]}
onChange={(value: string) => setAttributes({headingLevel: value})}
/>
<ToggleControl
label={__(`Activer le schema FAQPage`, `joule-child`)}
checked={attributes.enableSchema}
onChange={(value: boolean) => setAttributes({enableSchema: value})}
/>
<ToggleControl
label={__(`Ouvrir la premiere question`, `joule-child`)}
checked={attributes.openFirstItem}
onChange={(value: boolean) => setAttributes({openFirstItem: value})}
/>
</PanelBody>
</InspectorControls>
<div {...blockProps}>
<Preview attributes={attributes} />
<div className="ba-faq-schema__editor">
{items.map((item, index) => (
<div key={index} className="ba-faq-schema__editor-item">
<TextControl
label={__(`Question ${index + 1}`, `joule-child`)}
value={item.question}
onChange={(value: string) => updateItem(index, `question`, value)}
/>
<TextareaControl
label={__(`Reponse ${index + 1}`, `joule-child`)}
value={item.answer}
onChange={(value: string) => updateItem(index, `answer`, value)}
/>
<div className="ba-faq-schema__editor-actions">
<Button variant="secondary" onClick={() => moveUp(index)} disabled={index === 0}>
{__(`Monter`, `joule-child`)}
</Button>
<Button variant="secondary" onClick={() => moveDown(index)} disabled={index === items.length - 1}>
{__(`Descendre`, `joule-child`)}
</Button>
<Button variant="secondary" onClick={() => removeItem(index)}>
{__(`Supprimer`, `joule-child`)}
</Button>
</div>
</div>
))}
<Button variant="primary" onClick={addItem}>
{__(`Ajouter une question`, `joule-child`)}
</Button>
</div>
</div>
</>
)
}
export const save = () => null
Ici, save = () => null est normal. Le bloc est dynamique, donc le contenu final est rendu côté PHP. Le TSX ne sert qu’à l’expérience d’édition.
Intégration dans Roots, Sage et Radicle
Dans un projet Roots, la logique reste la même, mais l’organisation des fichiers est plus confortable. Le PHP continue de produire le front et le JSON-LD, tandis que les fichiers TSX sont branchés dans l’entrée éditeur. C’est généralement le découpage le plus sain pour un projet maintenu dans le temps.
add_action('init', function () {
register_block_type(get_theme_file_path('inc/blocks/faq-schema'));
});
import * as faqSchemaBlock from './editor/faq-schema.block'
window.wp.blocks.registerBlockType(faqSchemaBlock.name, faqSchemaBlock)
En pratique, Radicle n’invente pas une autre façon de faire des blocs WordPress. Il offre surtout une base plus propre pour organiser les assets, le build et la séparation entre front, back-office et logique serveur.
Checklist de validation avant mise en production
| Point | Vérification |
|---|---|
| Source de vérité | Le HTML et le JSON-LD utilisent les mêmes données |
| Filtrage | Les questions ou réponses vides sont ignorées |
| Sanitation | Les champs sont nettoyés avant rendu |
| Rendu dynamique | Le bloc n’enregistre pas de HTML final dans le post content |
| Éditeur | Ajout, suppression et réorganisation fonctionnent |
| Front | L’aperçu Gutenberg reste proche du rendu public |
| Validation | Le schema passe dans les outils de test |
FAQ
Faut-il encore utiliser FAQPage en 2026 ?
Oui, si la FAQ apporte une vraie valeur éditoriale et si le schema reflète exactement le contenu visible. En revanche, il vaut mieux éviter de le présenter comme une garantie d’affichage enrichi.
Pourquoi préférer un rendu dynamique en PHP ?
Parce qu’il permet de corriger le markup, filtrer les items incomplets, sanitiser les données et garder le front parfaitement aligné avec le JSON-LD sans devoir resauvegarder tous les contenus.
TSX est-il obligatoire dans Sage ou Radicle ?
Non. Mais sur un projet moderne, TSX améliore généralement la lisibilité et la maintenabilité de l’éditeur Gutenberg, surtout dès qu’un bloc contient une vraie logique d’édition.
Références utiles
Roots : compilation des assets
WordPress Developer : block metadata
WordPress Developer : enregistrement des blocs
Conversation
0 commentaires
Une question, un retour d’expérience ou une nuance utile ? Ajoute ton point de vue.
Pas encore de commentaires. Lance la discussion.