Aller au contenu

Créer un bloc Gutenberg FAQ avec schema SEO

Créer un bloc Gutenberg compatible avec schema SEO org - Bruno Antunes Freelance Wordpress

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.json pour la déclaration du bloc.
  • faq-schema.block.tsx pour l’interface Gutenberg.
  • render.php pour 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

PointVérification
Source de véritéLe HTML et le JSON-LD utilisent les mêmes données
FiltrageLes questions ou réponses vides sont ignorées
SanitationLes champs sont nettoyés avant rendu
Rendu dynamiqueLe bloc n’enregistre pas de HTML final dans le post content
ÉditeurAjout, suppression et réorganisation fonctionnent
FrontL’aperçu Gutenberg reste proche du rendu public
ValidationLe 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

Google Search Central : FAQ structured data

Schema.org : FAQPage

Roots : Radicle

Roots : Gutenberg dans Sage

Besoin d'un accompagnement technique ?

On peut cadrer votre besoin WordPress rapidement et sans engagement.
Planifier une consultation

Conversation

0 commentaires

Une question, un retour d’expérience ou une nuance utile ? Ajoute ton point de vue.

Écrire un commentaire

Pas encore de commentaires. Lance la discussion.

Ton retour

Ton adresse e-mail ne sera pas publiée. Les champs marqués * sont obligatoires.