I was working on a WordPress theme and wanted a reusable FAQ section that could be added to pages using the block editor.

There are plenty of FAQ plugins available, but I didn’t particularly want to install another plugin just to display a few questions and answers.

I also wanted more control over how the FAQs worked, including which questions were open by default, how the answers were structured, and the HTML generated on the frontend.

Since I was already building a custom WordPress theme, I decided to create my own Gutenberg block.

What started as a fairly simple accordion ended up involving nested blocks, React components, JavaScript and Schema.org structured data.

Creating two Gutenberg blocks

Rather than creating a single block with a list of questions and answers stored in an attribute, I split the implementation into two blocks.

The first is an FAQ repeater, which acts as a container for the questions.

The second is an FAQ item, containing an individual question and its answer.

This approach uses Gutenberg’s InnerBlocks functionality, which allows blocks to contain other blocks.

I registered both blocks using WordPress’s registerBlockType function.

1registerBlockType('theme/faq-repeater', {
2 apiVersion: 2,
3 title: 'FAQ Repeater',
4 icon: 'editor-ul',
5 category: 'common',
6 attributes: {
7 displaySetting: {
8 type: 'string',
9 default: 'none-open'
10 }
11 },
12 edit: FaqRepeaterEdit,
13 save: FaqRepeaterSave
14})
15 
16registerBlockType('theme/faq-item', {
17 apiVersion: 2,
18 title: 'FAQ Item',
19 icon: 'info',
20 category: 'common',
21 attributes: {
22 title: {
23 type: 'string',
24 source: 'text',
25 selector: 'h3'
26 },
27 blockId: {
28 type: 'string',
29 default: ''
30 }
31 },
32 edit: FaqItemEdit,
33 save: FaqItemSave,
34 parent: ['theme/faq-repeater']
35})
Copied!

The parent property is particularly useful here.

It prevents the FAQ item from being inserted as a standalone block elsewhere in the editor. It can only be added inside an FAQ repeater.

That makes the editing experience much more sensible.

Using InnerBlocks for the answers

One thing I didn’t want was to restrict answers to plain text.

An FAQ answer might contain paragraphs, lists, links, images or even embedded content.

Using InnerBlocks, I could allow the editor to use normal Gutenberg blocks inside each answer.

1export const FaqItemEdit = function (props) {
2 const {
3 attributes: { title },
4 setAttributes
5 } = props
6 
7 return (
8 <div {...useBlockProps()}>
9 <RichText
10 tagName="h3"
11 value={title}
12 onChange={(newTitle) => {
13 setAttributes({ title: newTitle })
14 }}
15 placeholder="Enter title"
16 />
17 
18 <InnerBlocks
19 allowedBlocks={[
20 'core/paragraph',
21 'core/list',
22 'core/image',
23 'core/heading',
24 'core/buttons',
25 'core/embed'
26 ]}
27 />
28 </div>
29 )
30}
Copied!

I’ve shortened the list of allowed blocks here, but the actual implementation supports quite a few more.

The question is entered using RichText, while the answer is built using whatever supported blocks the editor needs.

I think this is a much nicer solution than creating a custom textarea or rich text field for the answer.

It also means I don’t have to recreate functionality that Gutenberg already provides.

Making the repeater easier to use

The repeater itself only allows FAQ item blocks.

1<InnerBlocks
2 allowedBlocks={['theme/faq-item']}
3 template={[
4 ['theme/faq-item', {}, [
5 ['core/paragraph']
6 ]]
7 ]}
8/>
Copied!

I added a template so that when somebody inserts the repeater, it starts with an FAQ item containing a paragraph block.

It’s a small detail, but it avoids leaving the editor with an empty container and expecting them to work out what to insert.

They can immediately start entering their first question and answer.

Controlling which FAQs are open by default

I wanted the block to support three different display options.

  • All questions closed by default.
  • The first question open by default.
  • All questions open by default.

Rather than creating separate blocks for each layout, I added an attribute to the repeater and exposed it through the block inspector.

1<InspectorControls>
2 <RadioControl
3 label="Display:"
4 selected={displaySetting}
5 options={[
6 {
7 label: 'No items should be open by default',
8 value: 'none-open'
9 },
10 {
11 label: 'The first item should be open by default',
12 value: 'first-open'
13 },
14 {
15 label: 'All items should be open by default',
16 value: 'all-open'
17 }
18 ]}
19 onChange={(option) => {
20 setAttributes({ displaySetting: option })
21 }}
22 />
23</InspectorControls>
Copied!

The selected option is saved as a class on the repeater.

1const blockProps = useBlockProps.save({
2 className: `faq-block__repeater display--${displaySetting}`
3})
4 
5return (
6 <div {...blockProps}>
7 <InnerBlocks.Content />
8 </div>
9)
Copied!

That means the frontend JavaScript can read the class and decide which questions should be expanded when the page loads.

There’s no need for the JavaScript to know anything about Gutenberg or WordPress attributes.

It just works with the HTML.

Building the accordion

For the frontend interaction, I used a small JavaScript class.

Each FAQ has a button and a content container. Clicking the button changes the expanded state and shows or hides the answer.

I also wanted the behaviour where opening one question closes the others.

1toggleFaq () {
2 if (this.button.getAttribute('aria-expanded') === 'false') {
3 this.showFaq()
4 
5 this.siblings.forEach((elem) => {
6 if (elem !== this.faq) {
7 this.hideFaq(elem)
8 }
9 })
10 } else {
11 this.hideFaq()
12 }
13}
Copied!

The actual implementation uses jQuery’s slideToggle() to animate the answers opening and closing.

It’s fairly simple, but it does the job.

The JavaScript initialises each FAQ based on the repeater’s display setting.

For example, if the repeater has display--first-open, the first item remains expanded and the rest are collapsed.

The all-open option leaves every answer visible without initialising the individual accordion handlers, which makes it behave more like a permanently expanded FAQ list.

Making the markup accessible

I didn’t want the accordion to work only for people using a mouse.

Each question is an actual button inside a heading, rather than a clickable div.

1<h3>
2 <button
3 type="button"
4 className="expand-btn"
5 aria-controls={`faq-content-${blockId}`}
6 aria-expanded="true"
7 >
8 <span id={`faq-title-${blockId}`}>
9 {title}
10 </span>
11 </button>
12</h3>
Copied!

Using a button means keyboard users can interact with the accordion without needing any additional event handlers.

The aria-expanded attribute communicates whether the answer is currently open or closed.

I also link the button to the answer using aria-controls, with a unique identifier for each FAQ item.

The answer itself uses role="region" and aria-labelledby to connect it back to the question.

1<div
2 className="expand-content"
3 role="region"
4 aria-labelledby={`faq-title-${blockId}`}
5 id={`faq-content-${blockId}`}
6>
7 <InnerBlocks.Content />
8</div>
Copied!

Adding FAQ structured data

I also wanted search engines to understand that the content represented questions and answers.

Schema.orgschema.org favicon defines an FAQPage type, with individual Question and Answer entities.

Rather than generating JSON-LD, I added the structured data directly to the HTML using Microdata attributes.

Each FAQ item contains a question:

1<div
2 itemScope
3 itemProp="mainEntity"
4 itemType="https://schema.org/Question"
5>
6 <h3>
7 <span itemProp="name">{title}</span>
8 </h3>
9 
10 <div
11 itemScope
12 itemProp="acceptedAnswer"
13 itemType="https://schema.org/Answer"
14 >
15 <div itemProp="text">
16 <InnerBlocks.Content />
17 </div>
18 </div>
19</div>
Copied!

The outer page also needs to declare that it contains an FAQPage.

The complication is that a page might contain several FAQ repeaters, but it should still have a single FAQPage entity.

I didn’t want every repeater independently adding its own page-level structured data.

Detecting FAQ blocks in PHP

To handle that, I added a PHP function which checks whether the current page contains an FAQ repeater.

WordPress has a parse_blocks() function which converts block content into a structured array.

I could use that to search for the FAQ block, including when it’s nested inside another block.

1public function contains_faq_block( array $blocks ) {
2 foreach ( $blocks as $block ) {
3 if ( 'theme/faq-repeater' === $block['blockName'] ) {
4 return true;
5 }
6 
7 if (
8 ! empty( $block['innerBlocks'] ) &&
9 $this->contains_faq_block( $block['innerBlocks'] )
10 ) {
11 return true;
12 }
13 }
14 
15 return false;
16}
Copied!

If a repeater is found, I add the FAQPage structured data to the document’s HTML element.

1public function add_faq_schema_to_html_attributes( $output ) {
2 if ( ! is_singular() ) {
3 return $output;
4 }
5 
6 $content = get_post_field(
7 'post_content',
8 get_queried_object_id()
9 );
10 
11 if ( $this->contains_faq_block( parse_blocks( $content ) ) ) {
12 $output .= ' itemscope itemtype="https://schema.org/FAQPage"';
13 }
14 
15 return $output;
16}
Copied!

This means I don’t need to manually enable FAQ structured data on individual pages.

If the page contains the block, WordPress adds it automatically.

Separating functionality from styling

The FAQ blocks live in the parent WordPress theme, while the styling can be handled by the child theme.

This makes the functionality reusable without requiring every website to have exactly the same appearance.

The basic structure remains consistent, but colours, spacing and the expand/collapse indicators can all be customised.

For the expand indicator, I used an SVG mask in CSS rather than inserting another image element into every question.

That allows the icon to inherit the theme’s primary colour and rotate when the accordion state changes.

Was it worth building?

For a single FAQ section, a custom Gutenberg block might seem like unnecessary work.

But I wanted something that could be reused across different pages and websites, while remaining easy to edit.

Splitting it into a repeater and individual items gave me a much more flexible editing experience than storing all of the questions inside a single block attribute.

Using InnerBlocks also meant I didn’t need to build my own content editor for the answers.

The other useful part was keeping the editing experience, frontend behaviour and structured data separate.

Gutenberg handles editing the content. JavaScript handles the accordion. PHP detects the block on the page and adds the page-level schema.

But it gave me a reusable FAQ component which could be added and managed entirely through the WordPress editor, without installing a separate FAQ plugin.