If you want FAQPage structured data on a blog post, the usual approach is a dedicated CMS field: an array of question/answer pairs, separate from the post body, that a content editor fills in alongside the article. It works, but it's one more field type to build, one more thing for an editor to remember to fill in, and it duplicates content that often already exists in the article itself as a normal "FAQ" section.
I ended up doing something else: scanning the post's existing rich text for an FAQ-shaped section and generating the schema from that, with no extra field at all.
The convention
Instead of a new field, the rule is just an authoring convention: if a post has an H2 heading matching "FAQ" or "Frequently Asked Questions," everything after it — until the next H2 — is treated as a sequence of question/answer pairs, where each H3 is a question and the paragraph immediately following it is the answer.
## Frequently Asked Questions
### Is this actually necessary?
Only if you want the FAQ rich snippet in search results — the content still reads fine without it.
### What if I don't have an FAQ section?
Nothing happens. The schema generator just returns an empty array and no FAQPage script gets rendered.
Any writer already knows how to write an FAQ section like this. There's nothing new to learn, and nothing to fill in twice.
The extraction code
Working from a Lexical JSON tree (this generalizes to any block-based rich text format), the scan is a straightforward loop:
interface LexicalNode {
type: string;
tag?: string;
text?: string;
children?: LexicalNode[];
}
function nodeText(node: LexicalNode): string {
if (node.text) return node.text;
if (!node.children) return '';
return node.children.map(nodeText).join('');
}
function extractFaqJsonLd(content: unknown) {
const blocks = (content as any)?.root?.children ?? [];
const faqStart = blocks.findIndex(
(b: LexicalNode) =>
b.type === 'heading' &&
b.tag === 'h2' &&
/frequently asked questions|^faq/i.test(nodeText(b))
);
if (faqStart === -1) return [];
const faqs: { question: string; answer: string }[] = [];
for (let i = faqStart + 1; i < blocks.length; i++) {
const block = blocks[i];
if (block.type === 'heading' && block.tag === 'h2') break; // next section, stop
if (block.type === 'heading' && block.tag === 'h3') {
const question = nodeText(block).trim();
const next = blocks[i + 1];
const answer = next?.type === 'paragraph' ? nodeText(next).trim() : '';
if (question && answer) faqs.push({ question, answer });
}
}
return faqs;
}
nodeText recursively flattens any node's text content, so it doesn't care whether a heading has one text run or several (bold spans, links, whatever) — it just concatenates everything under it.
Rendering the schema
Once you have the pairs, the JSON-LD itself is small:
const faqs = extractFaqJsonLd(post.content);
const faqJsonLd = faqs.length
? {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((f) => ({
'@type': 'Question',
name: f.question,
acceptedAnswer: { '@type': 'Answer', text: f.answer },
})),
}
: null;
{faqJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
)}
Why this over a dedicated field
The honest tradeoff: a dedicated field is more explicit and more robust against someone writing an FAQ section that doesn't quite match the convention (wrong heading level, no paragraph directly after the question). Scanning content is more fragile in that specific way.
What it buys back is that every post automatically gets the structured data the moment it has a normal-looking FAQ section, with zero extra CMS schema, zero extra editor UI, and no way for the visible content and the structured data to drift out of sync — because they're the same content. For a blog where FAQ sections are common but not universal, that traded off in favor of "just write it like a normal FAQ section" over "remember to also fill in this separate field."
Using this on DevFixel's blog. If you're doing FAQPage schema a different way — especially anything more robust against the heading-order edge case — I'd be curious how you're handling it.
Top comments (0)