Forum Thread Page Schema

One community thread: a question with answers becomes a QAPage, anything else a DiscussionForumPosting. Posters keep their profile @id, and a thread about a product points at it.

When To Use It

Use on a page that shows one user-generated thread from a forum or community. The builder decides the type from the thread: a question that collects answers is Q&A, a trip report or open discussion is a forum posting.

Each poster is the same Person @id as their member profile, and a thread about a product points at that product's @id, so the question is tied to the product it asks about.

How The Pieces Connect

Every node has a stable @id. A node is written out once, where it belongs, and everywhere else it is referenced by that @id alone. This is the map for this page, generated from the output below.

Nodes and the @id links between them
@idDefined onTypeReferenced by
/community/threads/ridgeline-shell-winter-layering/#webpageThis pageQAPageTop-level node
/community/members/jkrause/#personThis pagePersonNested in QAPage.mainEntity.author
/community/members/maya-okafor/#personThis pagePersonNested in QAPage.mainEntity.acceptedAnswer.author
/community/members/trailmix/#personThis pagePersonNested in QAPage.mainEntity.suggestedAnswer.author
/community/threads/ridgeline-shell-winter-layering/#breadcrumbThis pageBreadcrumbListQAPage.breadcrumb
/#websiteHomepageWebSiteQAPage.isPartOf
/jackets/ridgeline-rain-shell/#product-groupProduct pageProductGroupQAPage.mainEntity.about

The Builder

One function turns the page's data into the whole graph. It drops anything Google would reject instead of shipping it half built. The input below includes rows it is meant to drop, so you can see the logic work.

// Forum thread page schema: one thread from the store's community forum.
// A question that collects answers becomes a QAPage; any other thread (a
// trip report, a discussion) becomes a DiscussionForumPosting. Posters are
// the same Person @id as their forum profile page, and a thread about a
// product points at that product's @id.

const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(Z|[+-]\d{2}:\d{2})$/;

const clean = (v) => (typeof v === 'string' ? v.trim() : v);
const isUrl = (v) => typeof v === 'string' && /^https:\/\/[^\s]+$/.test(v);
const compact = (obj) => {
  if (Array.isArray(obj)) {
    const out = obj.map(compact).filter((v) => v !== undefined);
    return out.length ? out : undefined;
  }
  if (obj && typeof obj === 'object') {
    const out = {};
    for (const [k, v] of Object.entries(obj)) {
      const c = compact(v);
      if (c !== undefined) out[k] = c;
    }
    return Object.keys(out).length ? out : undefined;
  }
  return obj === null || obj === '' ? undefined : obj;
};

function breadcrumb(trail, id) {
  const items = (trail || []).filter((c) => c.name && isUrl(c.url));
  if (items.length < 2) return undefined;
  return {
    '@type': 'BreadcrumbList',
    '@id': id,
    itemListElement: items.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(c.name), item: c.url })),
  };
}

function person(u) {
  if (!u || !u.name) return undefined;
  return compact({
    '@type': 'Person',
    '@id': isUrl(u.profileUrl) ? `${u.profileUrl}#person` : undefined,
    name: clean(u.name),
    url: isUrl(u.profileUrl) ? u.profileUrl : undefined,
  });
}

// A post is only kept when it has visible text, an author and a timestamp
// with a timezone; deleted and moderated posts are skipped.
const usable = (p) => p && !p.removed && p.text && p.text.trim() && p.author && ISO_DATE_TIME.test(p.date || '');

function buildForumThreadPageSchema(source) {
  const site = source.site || {};
  const page = source.page || {};
  const t = source.thread || {};
  if (!isUrl(site.url) || !isUrl(page.url) || !t.title || !usable(t.opening)) return null;

  const root = site.url.replace(/\/+$/, '');
  const pageUrl = page.url;
  const replies = (t.replies || []).filter(usable);
  const about = isUrl(t.aboutUrl) ? { '@id': t.aboutUrl } : undefined;

  // Q&A only fits a thread that asks one question and collects answers. A
  // site-written FAQ or a thread with no replies is not a QAPage.
  const isQuestion = t.kind === 'question' && replies.length > 0;

  let main;
  if (isQuestion) {
    const answer = (r) => compact({
      '@type': 'Answer',
      text: clean(r.text),
      author: person(r.author),
      datePublished: r.date,
      upvoteCount: Number.isInteger(r.upvotes) ? r.upvotes : undefined,
      url: `${pageUrl}#post-${r.id}`,
    });
    const accepted = replies.find((r) => r.accepted);
    main = compact({
      '@type': 'QAPage',
      '@id': `${pageUrl}#webpage`,
      url: pageUrl,
      name: clean(page.title) || clean(t.title),
      isPartOf: { '@id': `${root}/#website` },
      mainEntity: {
        '@type': 'Question',
        name: clean(t.title),
        text: clean(t.opening.text),
        author: person(t.opening.author),
        datePublished: t.opening.date,
        answerCount: replies.length,
        upvoteCount: Number.isInteger(t.opening.upvotes) ? t.opening.upvotes : undefined,
        about,
        acceptedAnswer: accepted ? answer(accepted) : undefined,
        suggestedAnswer: replies.filter((r) => r !== accepted).map(answer),
      },
    });
  } else {
    main = compact({
      '@type': 'DiscussionForumPosting',
      '@id': `${pageUrl}#post-${t.opening.id}`,
      headline: clean(t.title),
      text: clean(t.opening.text),
      author: person(t.opening.author),
      datePublished: t.opening.date,
      url: pageUrl,
      about,
      mainEntityOfPage: { '@id': `${pageUrl}#webpage` },
      commentCount: replies.length,
      comment: replies.map((r) => compact({
        '@type': 'Comment',
        text: clean(r.text),
        author: person(r.author),
        datePublished: r.date,
        url: `${pageUrl}#post-${r.id}`,
      })),
    });
  }

  const crumbs = breadcrumb(page.breadcrumbs, `${pageUrl}#breadcrumb`);
  if (isQuestion) {
    if (crumbs) main.breadcrumb = { '@id': crumbs['@id'] };
    return { '@context': 'https://schema.org', '@graph': [main, crumbs].filter(Boolean) };
  }
  const webpage = compact({
    '@type': 'WebPage',
    '@id': `${pageUrl}#webpage`,
    url: pageUrl,
    name: clean(page.title) || clean(t.title),
    isPartOf: { '@id': `${root}/#website` },
    breadcrumb: crumbs ? { '@id': crumbs['@id'] } : undefined,
    mainEntity: { '@id': main['@id'] },
  });
  return { '@context': 'https://schema.org', '@graph': [webpage, crumbs, main].filter(Boolean) };
}

Validation

The output above was run through the SchemaCDN validator, which checks every rule in Google's documentation for each feature. It was validated together with the homepage graph, so the @id links resolve. Result: no errors, and eligible for:

Q&A Breadcrumb

Left Out On Purpose

  • The post removed by a moderator. Hidden or deleted posts are not marked up.
  • dateModified on posts that were never edited.