Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 5 min read

Stop Hand-Writing FAQ Schema: Generate FAQPage JSON-LD from Your Markdown in Next.js

On HebrewGlot, a platform for learning Hebrew, we have around 160 MDX articles in two languages. Almost every one ends with a "Frequently asked questions" section, because people really do search in question form: "Is He

On HebrewGlot, a platform for learning Hebrew, we have around 160 MDX articles in two languages. Almost every one ends with a "Frequently asked questions" section, because people really do search in question form: "Is Hebrew hard to learn?", "How long does it take?"

We wanted FAQPage structured data on all of them. Writing JSON-LD by hand for 160 articles, and keeping it in sync every time an author edits a question, was never going to happen. So we generate it from the Markdown the author already wrote.

This post walks through the ~80 lines that do it.

A quick reality check first

Google cut back FAQ rich results in 2023: the expandable Q&A under a search result now mostly shows for well-known government and health sites. So don't do this expecting a big visual snippet in Google.

We still do it, because:

  • the schema is still valid and other search engines (we get a lot of Bing traffic) and AI assistants consume structured Q&A;
  • it forces a clean, consistent FAQ format across all articles;
  • it costs nothing after it's written once.

The contract with authors

Authors write normal Markdown. The only rules:

  1. The section heading must look like an FAQ heading (## FAQ, ## Frequently asked questions, ## Common questions, and the Russian equivalents in our case).
  2. Each question is either a ### heading or a bold line, and it contains a ?.
  3. The answer is the text below it, up to the next blank line.
## Frequently asked questions

### Is Hebrew hard to learn?

It's harder than Spanish and easier than Arabic for most English speakers...

**How many letters are in the Hebrew alphabet?**

22, plus 5 final forms...

Step 1: find the FAQ section

export interface FaqItem {
  question: string;
  answer: string;
}

const FAQ_HEADING_RE =
  /(faq|frequently asked|common questions|часто Π·Π°Π΄Π°Π²Π°|частыС вопрос|вопросы ΠΈ ΠΎΡ‚Π²Π΅Ρ‚Ρ‹)/i;

function findFaqSection(lines: string[]): string[] {
  let start = -1;
  let level = 2;

  for (let i = 0; i < lines.length; i++) {
    const m = lines[i].match(/^(#{2,3})\s+(.+)$/);
    if (m && FAQ_HEADING_RE.test(m[2])) {
      start = i + 1;
      level = m[1].length;
      break;
    }
  }
  if (start === -1) return [];

  // The section ends at the next heading of the same or higher level.
  let end = lines.length;
  for (let i = start; i < lines.length; i++) {
    const m = lines[i].match(/^(#{1,6})\s+/);
    if (m && m[1].length <= level) {
      end = i;
      break;
    }
  }
  return lines.slice(start, end);
}

Tracking the heading level matters. If the FAQ is an ##, the ### questions inside it must not end the section.

Step 2: recognize a question

function stripMarkdown(text: string): string {
  return text
    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links β†’ text
    .replace(/[*_`]+/g, '')                  // emphasis / code
    .replace(/<[^>]+>/g, '')                 // HTML / JSX tags (it's MDX!)
    .replace(/\s+/g, ' ')
    .trim();
}

function parseQuestion(line: string): string | null {
  const t = line.trim();

  const heading = t.match(/^#{3,4}\s+(.+)$/);       // ### Question?
  if (heading) {
    const q = stripMarkdown(heading[1]).replace(/^Q:\s*/i, '');
    return q.includes('?') ? q : null;
  }

  const bold = t.match(/^\*\*(.+?)\*\*:?\s*$/);      // **Question?**
  if (bold) {
    const q = stripMarkdown(bold[1]).replace(/^Q:\s*/i, '');
    return q.includes('?') ? q : null;
  }

  return null;
}

The ? check is a cheap but effective guard: a bold line like **Tip:** inside an answer won't be mistaken for a new question.

Stripping JSX tags is specific to MDX. Our articles contain components like <CtaBox />, and schema text must be plain.

Step 3: pair questions with answers

export function extractFaq(content: string): FaqItem[] {
  const section = findFaqSection(content.split('\n'));
  const items: FaqItem[] = [];
  let current: FaqItem | null = null;
  const buffer: string[] = [];

  const flush = () => {
    if (current) {
      const answer = stripMarkdown(buffer.join(' ')).replace(/^A:\s*/i, '');
      if (answer) items.push({ ...current, answer });
    }
    current = null;
    buffer.length = 0;
  };

  for (const raw of section) {
    const line = raw.trim();
    if (line === '---' || line === '***') continue;

    const q = parseQuestion(line);
    if (q) {
      flush();
      current = { question: q, answer: '' };
    } else if (current && line) {
      buffer.push(line);
    } else if (!line && buffer.length > 0) {
      flush(); // blank line after an answer closes the pair
    }
  }
  flush();

  // A single Q&A isn't an FAQ; don't emit schema for it.
  return items.length >= 2 ? items : [];
}

Design choice: the answer is the first paragraph only. Authors sometimes add a long second paragraph or a table. For structured data, a short, direct answer is better anyway.

Step 4: build the JSON-LD

export function buildFaqJsonLd(content: string) {
  const faq = extractFaq(content);
  if (faq.length === 0) return null;

  return {
    '@context': 'https://schema.org',
    '@type': 'FAQPage',
    mainEntity: faq.map((item) => ({
      '@type': 'Question',
      name: item.question,
      acceptedAnswer: { '@type': 'Answer', text: item.answer },
    })),
  };
}

Step 5: render it in the App Router

In the article page (a server component), next to where you render the MDX:

const faqJsonLd = buildFaqJsonLd(post.content);

return (
  <>
    {faqJsonLd && (
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={ {
          // Escape "<" so article text can never close the script tag.
          __html: JSON.stringify(faqJsonLd).replace(/</g, '\\u003c'),
        } }
      />
    )}
    <ArticleBody content={post.content} />
  </>
);

The \u003c escape is easy to forget. Content comes from Markdown files you control, but if a question ever contains </script>, unescaped JSON would break the page, or worse.

Test it against real content

The parser is only as good as your authors' consistency, so it's worth running it against every real article in CI, not only against fixtures:

import { getAllPosts } from '@/lib/blog';
import { extractFaq } from '@/lib/blog-faq';

test.each(['en', 'ru'])('every %s article with an FAQ heading yields β‰₯ 2 items', (locale) => {
  for (const post of getAllPosts(locale)) {
    const hasFaqHeading = /^#{2,3}\s+.*(faq|frequently asked|частыС вопрос)/im.test(post.content);
    if (hasFaqHeading) {
      expect(extractFaq(post.content).length).toBeGreaterThanOrEqual(2);
    }
  }
});

When a test like this fails, the usual cause is an FAQ heading whose questions are plain paragraphs. Fix the content, not the parser: every special case you add makes it harder for authors to know the rules.

Results

Nearly every article in both languages now ships valid FAQPage markup, and authors never think about JSON-LD. Adding a question is just adding a ### line.

If you're running a Markdown/MDX blog on Next.js, this is a one-afternoon change. And if you happen to be learning Hebrew, our blog is where all of this runs.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.