Discussion Forum Schema Framework
Use conditional-logic schema generation to keep Discussion Forum markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralDiscussion Forum documentationUse for user-generated, forum-style threads where a primary post and its replies are publicly visible: community forums, generic forum platforms, and social media platforms with user posts. Do not use it for publisher-written articles (even with user comments) or product reviews, and use QAPage markup instead when the forum follows a question-and-answer pattern.
Key Implementation Documentation Highlights
- Types: DiscussionForumPosting, or SocialMediaPosting, which has the same requirements.
- Required on the post: author (Person or Organization, with author.name) and datePublished (ISO 8601), plus at least one of text, image, or video. The content requirement does not apply when the posting represents a post on another page.
- Recommended on the post: author.url, comment, commentCount, dateModified, headline, interactionStatistic, isPartOf, mainEntityOfPage, sharedContent, and url. Conditional: creativeWorkStatus set to Deleted for a removed post kept for context, and digitalSourceType for content generated by an LLM (TrainedAlgorithmicMediaDigitalSource) or a simpler bot (AlgorithmicMediaDigitalSource).
- Each Comment has the same shape: author with name and datePublished are required, plus one of text, image, or video. Nest replies as a tree that mirrors the forum's threading, in the order they appear on the page.
- interactionStatistic.interactionType must be LikeAction, DislikeAction, ViewAction, CommentAction, ReplyAction, or ShareAction, with an integer userInteractionCount.
- image holds inline images only: no default, icon, placeholder, or author images. headline should not duplicate or truncate the text when the post has no separate title.
- For multi-page threads, later pages include the original post, with url set to the first page URL.
- Use the Rich Results Test and URL Inspection to validate rendering, eligibility, and crawlability.
Rules the framework enforces before it emits a posting:
- Q&A threads return nothing here and go to the QAPage framework instead.
- Posts and comments without an author name, a valid ISO 8601 date-time, or any text, image, or video are dropped; a dropped comment takes its replies with it.
- Interaction counts map to the six supported action types and must be non-negative integers; anything else is skipped.
- commentCount is never lower than the number of comments in the markup, and a dateModified earlier than datePublished is dropped.
Template Approach
const pageData = {
title: "Rendering delays after migrating to a JS framework",
body: "Since moving our product pages to client-side rendering, new URLs take weeks to show rendered content.",
url: "https://forum.example.com/t/rendering-delays-after-migration/4821",
postedAt: "2026-09-28T10:14:00-04:00",
authorName: "mkhan",
authorUrl: "https://forum.example.com/u/mkhan"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
"headline": pageData.title,
"text": pageData.body,
"url": pageData.url,
"datePublished": pageData.postedAt,
"author": { "@type": "Person", "name": pageData.authorName, "url": pageData.authorUrl }
};
{
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
"headline": "Rendering delays after migrating to a JS framework",
"text": "Since moving our product pages to client-side rendering, new URLs take weeks to show rendered content.",
"url": "https://forum.example.com/t/rendering-delays-after-migration/4821",
"datePublished": "2026-09-28T10:14:00-04:00",
"author": {
"@type": "Person",
"name": "mkhan",
"url": "https://forum.example.com/u/mkhan"
}
}
Conditional-logic Framework
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
function isAbsoluteUrl(value) {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && Boolean(url.hostname);
} catch {
return false;
}
}
function isRealDate(y, m, d) {
const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d)));
return date.getUTCMonth() === Number(m) - 1 && date.getUTCDate() === Number(d);
}
// Calendar date (YYYY-MM-DD); a full date-time is cut to its date part.
function toIsoDate(value) {
if (typeof value !== "string") return null;
const v = value.trim();
const m = v.match(ISO_DATE) || v.match(ISO_DATE_TIME);
return m && isRealDate(m[1], m[2], m[3]) ? v.slice(0, 10) : null;
}
// Date-time with Z or an offset (kept as written), or a plain date.
// A date-time without a timezone is rejected instead of guessed.
function toIsoDateTime(value) {
if (typeof value !== "string") return null;
const v = value.trim();
const m = v.match(ISO_DATE) || v.match(ISO_DATE_TIME);
return m && isRealDate(m[1], m[2], m[3]) ? v : null;
}
// Drop null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((v) => v !== undefined);
return items.length ? items : undefined;
}
if (value && typeof value === "object") {
const out = {};
for (const [key, v] of Object.entries(value)) {
const c = compact(v);
if (c !== undefined) out[key] = c;
}
return Object.keys(out).length ? out : undefined;
}
if (value === null || value === undefined || value === "") return undefined;
return value;
}
// Page gates: indexable, self-canonical, and the marked-up content is visible.
function passesPageGates(source) {
if (!source.isIndexable || !source.isContentVisible) return false;
if (!isAbsoluteUrl(source.url)) return false;
return !source.canonicalUrl || source.canonicalUrl === source.url;
}
const INTERACTION_TYPES = {
likes: "https://schema.org/LikeAction",
dislikes: "https://schema.org/DislikeAction",
views: "https://schema.org/ViewAction",
comments: "https://schema.org/CommentAction",
replies: "https://schema.org/ReplyAction",
shares: "https://schema.org/ShareAction"
};
const DIGITAL_SOURCE_TYPES = {
llm: "https://schema.org/TrainedAlgorithmicMediaDigitalSource",
bot: "https://schema.org/AlgorithmicMediaDigitalSource"
};
// Q&A threads belong in QAPage markup; social platforms use SocialMediaPosting.
function chooseForumPostingType(source) {
if (source.isQuestionAnswerThread) return null;
return source.platform === "social" ? "SocialMediaPosting" : "DiscussionForumPosting";
}
function buildAuthor(author) {
if (!author || !author.name) return null;
return {
"@type": author.isOrganization ? "Organization" : "Person",
"name": author.name,
"url": isAbsoluteUrl(author.url) ? author.url : null
};
}
function buildStats(stats) {
return Object.entries(stats || {})
.filter(([key, count]) => INTERACTION_TYPES[key] && Number.isInteger(count) && count >= 0)
.map(([key, count]) => ({ "@type": "InteractionCounter", "interactionType": INTERACTION_TYPES[key], "userInteractionCount": count }));
}
// Shared fields for posts and comments. Returns null when a required field is missing.
function buildEntry(entry, { allowNoContent = false } = {}) {
const author = buildAuthor(entry.author);
const datePublished = toIsoDateTime(entry.datePublished);
if (!author || !datePublished) return null;
// Inline media only: never avatars, icons or placeholders.
const image = (entry.images || []).filter(isAbsoluteUrl);
const text = (entry.text || "").trim();
if (!text && !image.length && !entry.video && !allowNoContent) return null;
let dateModified = toIsoDateTime(entry.dateModified);
if (dateModified && Date.parse(dateModified) < Date.parse(datePublished)) dateModified = null;
const comment = (entry.comments || []).map((c) => buildEntry(c)).filter(Boolean)
.map((c) => ({ "@type": "Comment", ...c })); // replies stay nested, in page order
return {
"author": author,
"datePublished": datePublished,
"dateModified": dateModified,
"text": text,
"image": image,
"video": entry.video || null,
"url": isAbsoluteUrl(entry.url) ? entry.url : null,
"creativeWorkStatus": entry.isDeleted ? "Deleted" : null,
"digitalSourceType": DIGITAL_SOURCE_TYPES[entry.generatedBy] || null,
"interactionStatistic": buildStats(entry.stats),
"comment": comment,
"commentCount": Number.isInteger(entry.commentCount) && entry.commentCount >= comment.length ? entry.commentCount : null
};
}
function countComments(comments) {
return (comments || []).reduce((n, c) => n + 1 + countComments(c.comment), 0);
}
function buildDiscussionForumSchema(source) {
if (!passesPageGates(source)) return null;
const type = chooseForumPostingType(source);
if (!type) return null;
// Text, image or video is required unless the posting represents a post on another page.
const postUrl = isAbsoluteUrl(source.postUrl) ? source.postUrl : source.url;
const entry = buildEntry(source.post || {}, { allowNoContent: postUrl !== source.url });
if (!entry) return null;
const post = source.post;
const headline = (post.headline || "").trim();
// A headline that repeats or truncates the text adds nothing: drop it.
const keepHeadline = headline && !entry.text.startsWith(headline.replace(/(\.\.\.|…)$/, ""));
const marked = countComments(entry.comment);
return compact({
"@context": "https://schema.org",
"@type": type,
"headline": keepHeadline ? headline : null,
...entry,
"commentCount": Math.max(entry.commentCount || 0, marked) || null,
"url": postUrl, // first page URL for multi-page threads
"mainEntityOfPage": source.isSinglePostPage ? source.url : null,
"isPartOf": isAbsoluteUrl(source.subforumUrl) ? source.subforumUrl : null
}) || null;
}
// Usage
// const source = {
// "isIndexable": true,
// "isContentVisible": true,
// "url": "https://forum.example.com/t/rendering-delays-after-migration/4821",
// "canonicalUrl": "https://forum.example.com/t/rendering-delays-after-migration/4821",
// "isSinglePostPage": true,
// "subforumUrl": "https://forum.example.com/c/technical-seo",
// "post": {
// "headline": "Rendering delays after migrating to a JS framework",
// "text": "Since moving our product pages to client-side rendering, new URLs take two to three weeks to show rendered content in URL Inspection. Has anyone measured this?",
// "images": [
// "https://forum.example.com/uploads/render-timeline.png"
// ],
// "author": {
// "name": "mkhan",
// "url": "https://forum.example.com/u/mkhan"
// },
// "datePublished": "2026-09-28T10:14:00-04:00",
// "dateModified": "2026-09-28T11:02:00-04:00",
// "stats": {
// "likes": 18,
// "views": 1240
// },
// "commentCount": 3,
// "comments": [
// {
// "text": "We saw the same pattern. Server-rendering the main content cut it to a few days.",
// "author": {
// "name": "priya_r",
// "url": "https://forum.example.com/u/priya_r"
// },
// "datePublished": "2026-09-28T12:40:00-04:00",
// "url": "https://forum.example.com/t/rendering-delays-after-migration/4821/2",
// "stats": {
// "likes": 9
// },
// "comments": [
// {
// "text": "Did you keep hydration for the reviews widget?",
// "author": {
// "name": "mkhan",
// "url": "https://forum.example.com/u/mkhan"
// },
// "datePublished": "2026-09-28T13:05:00-04:00",
// "url": "https://forum.example.com/t/rendering-delays-after-migration/4821/3"
// }
// ]
// },
// {
// "text": "Check whether your sitemap lastmod values changed during the migration.",
// "author": {
// "name": "dlee",
// "url": "https://forum.example.com/u/dlee"
// },
// "datePublished": "2026-09-29T08:20:00-04:00",
// "url": "https://forum.example.com/t/rendering-delays-after-migration/4821/4"
// }
// ]
// }
// };
// const jsonLd = buildDiscussionForumSchema(source); // null means: emit nothing
import re
from datetime import date
from urllib.parse import urlparse
from datetime import datetime, timezone
ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
ISO_DATE_TIME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$")
def is_absolute_url(value):
if not isinstance(value, str):
return False
try:
parsed = urlparse(value)
except ValueError:
return False
return parsed.scheme in ("http", "https") and bool(parsed.hostname)
def is_real_date(y, m, d):
try:
date(int(y), int(m), int(d))
return True
except ValueError:
return False
# Calendar date (YYYY-MM-DD); a full date-time is cut to its date part.
def to_iso_date(value):
if not isinstance(value, str):
return None
v = value.strip()
m = ISO_DATE.match(v) or ISO_DATE_TIME.match(v)
return v[:10] if m and is_real_date(m[1], m[2], m[3]) else None
# Date-time with Z or an offset (kept as written), or a plain date.
# A date-time without a timezone is rejected instead of guessed.
def to_iso_date_time(value):
if not isinstance(value, str):
return None
v = value.strip()
m = ISO_DATE.match(v) or ISO_DATE_TIME.match(v)
return v if m and is_real_date(m[1], m[2], m[3]) else None
# Drop None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [c for c in (compact(v) for v in value) if c is not None]
return items or None
if isinstance(value, dict):
out = {}
for key, v in value.items():
c = compact(v)
if c is not None:
out[key] = c
return out or None
if value is None or value == "":
return None
return value
# Page gates: indexable, self-canonical, and the marked-up content is visible.
def passes_page_gates(source):
if not source.get("isIndexable") or not source.get("isContentVisible"):
return False
if not is_absolute_url(source.get("url")):
return False
return not source.get("canonicalUrl") or source.get("canonicalUrl") == source.get("url")
INTERACTION_TYPES = {
"likes": "https://schema.org/LikeAction",
"dislikes": "https://schema.org/DislikeAction",
"views": "https://schema.org/ViewAction",
"comments": "https://schema.org/CommentAction",
"replies": "https://schema.org/ReplyAction",
"shares": "https://schema.org/ShareAction",
}
DIGITAL_SOURCE_TYPES = {
"llm": "https://schema.org/TrainedAlgorithmicMediaDigitalSource",
"bot": "https://schema.org/AlgorithmicMediaDigitalSource",
}
def to_timestamp(value):
if ISO_DATE.match(value):
return datetime.fromisoformat(value).replace(tzinfo=timezone.utc).timestamp()
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
# Q&A threads belong in QAPage markup; social platforms use SocialMediaPosting.
def choose_forum_posting_type(source):
if source.get("isQuestionAnswerThread"):
return None
return "SocialMediaPosting" if source.get("platform") == "social" else "DiscussionForumPosting"
def build_author(author):
if not author or not author.get("name"):
return None
return {
"@type": "Organization" if author.get("isOrganization") else "Person",
"name": author["name"],
"url": author.get("url") if is_absolute_url(author.get("url")) else None,
}
def build_stats(stats):
return [
{"@type": "InteractionCounter", "interactionType": INTERACTION_TYPES[key], "userInteractionCount": count}
for key, count in (stats or {}).items()
if key in INTERACTION_TYPES and isinstance(count, int) and not isinstance(count, bool) and count >= 0
]
# Shared fields for posts and comments. Returns None when a required field is missing.
def build_entry(entry, allow_no_content=False):
author = build_author(entry.get("author"))
date_published = to_iso_date_time(entry.get("datePublished"))
if not author or not date_published:
return None
# Inline media only: never avatars, icons or placeholders.
image = [u for u in entry.get("images") or [] if is_absolute_url(u)]
text = (entry.get("text") or "").strip()
if not text and not image and not entry.get("video") and not allow_no_content:
return None
date_modified = to_iso_date_time(entry.get("dateModified"))
if date_modified and to_timestamp(date_modified) < to_timestamp(date_published):
date_modified = None
built = [build_entry(c) for c in entry.get("comments") or []]
comment = [{"@type": "Comment", **c} for c in built if c] # replies stay nested, in page order
count = entry.get("commentCount")
return {
"author": author,
"datePublished": date_published,
"dateModified": date_modified,
"text": text,
"image": image,
"video": entry.get("video"),
"url": entry.get("url") if is_absolute_url(entry.get("url")) else None,
"creativeWorkStatus": "Deleted" if entry.get("isDeleted") else None,
"digitalSourceType": DIGITAL_SOURCE_TYPES.get(entry.get("generatedBy")),
"interactionStatistic": build_stats(entry.get("stats")),
"comment": comment,
"commentCount": count if isinstance(count, int) and count >= len(comment) else None,
}
def count_comments(comments):
return sum(1 + count_comments(c.get("comment")) for c in comments or [])
def build_discussion_forum_schema(source):
if not passes_page_gates(source):
return None
posting_type = choose_forum_posting_type(source)
if not posting_type:
return None
# Text, image or video is required unless the posting represents a post on another page.
post_url = source["postUrl"] if is_absolute_url(source.get("postUrl")) else source["url"]
entry = build_entry(source.get("post") or {}, allow_no_content=post_url != source["url"])
if not entry:
return None
headline = (source["post"].get("headline") or "").strip()
# A headline that repeats or truncates the text adds nothing: drop it.
keep_headline = headline and not entry["text"].startswith(re.sub(r"(\.\.\.|…)$", "", headline))
marked = count_comments(entry["comment"])
return compact({
"@context": "https://schema.org",
"@type": posting_type,
"headline": headline if keep_headline else None,
**entry,
"commentCount": max(entry["commentCount"] or 0, marked) or None,
"url": post_url, # first page URL for multi-page threads
"mainEntityOfPage": source["url"] if source.get("isSinglePostPage") else None,
"isPartOf": source["subforumUrl"] if is_absolute_url(source.get("subforumUrl")) else None,
})
# Usage: json_ld = build_discussion_forum_schema(source) # same source dict as the JS example; None means emit nothing
{
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
"headline": "Rendering delays after migrating to a JS framework",
"author": {
"@type": "Person",
"name": "mkhan",
"url": "https://forum.example.com/u/mkhan"
},
"datePublished": "2026-09-28T10:14:00-04:00",
"dateModified": "2026-09-28T11:02:00-04:00",
"text": "Since moving our product pages to client-side rendering, new URLs take two to three weeks to show rendered content in URL Inspection. Has anyone measured this?",
"image": [
"https://forum.example.com/uploads/render-timeline.png"
],
"url": "https://forum.example.com/t/rendering-delays-after-migration/4821",
"interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/LikeAction",
"userInteractionCount": 18
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/ViewAction",
"userInteractionCount": 1240
}
],
"comment": [
{
"@type": "Comment",
"author": {
"@type": "Person",
"name": "priya_r",
"url": "https://forum.example.com/u/priya_r"
},
"datePublished": "2026-09-28T12:40:00-04:00",
"text": "We saw the same pattern. Server-rendering the main content cut it to a few days.",
"url": "https://forum.example.com/t/rendering-delays-after-migration/4821/2",
"interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/LikeAction",
"userInteractionCount": 9
}
],
"comment": [
{
"@type": "Comment",
"author": {
"@type": "Person",
"name": "mkhan",
"url": "https://forum.example.com/u/mkhan"
},
"datePublished": "2026-09-28T13:05:00-04:00",
"text": "Did you keep hydration for the reviews widget?",
"url": "https://forum.example.com/t/rendering-delays-after-migration/4821/3"
}
]
},
{
"@type": "Comment",
"author": {
"@type": "Person",
"name": "dlee",
"url": "https://forum.example.com/u/dlee"
},
"datePublished": "2026-09-29T08:20:00-04:00",
"text": "Check whether your sitemap lastmod values changed during the migration.",
"url": "https://forum.example.com/t/rendering-delays-after-migration/4821/4"
}
],
"commentCount": 3,
"mainEntityOfPage": "https://forum.example.com/t/rendering-delays-after-migration/4821",
"isPartOf": "https://forum.example.com/c/technical-seo"
}
The Output As An Entity Graph
The conditional-logic output above, parsed in your browser and drawn as the graph a crawler builds from it. Every nested object is a node and the property that holds it is the edge. Tap a node to inspect its properties, or drag to rearrange.
Why Conditional Logic Is Better Than Static Templates
- Builds the comment tree recursively from the thread data, so nesting and order match what users see.
- Handles deleted posts, edited posts, bot or LLM generated replies, and multi-page threads without separate templates.
- Keeps interaction statistics limited to supported action types and real counts.
- Routes question-and-answer threads to QAPage markup instead of emitting the wrong type.
Property Reference
What Google's Discussion forum documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
Required, one of: To represent the content of the post, include one of text, image, or video. Not required if representing a post on another page (with an external url).
Required, one of: In comment: a comment must include one of text, image, or video.
| Property | Expects | Notes | |
|---|---|---|---|
| author | Required | Person or Organization, can repeat | Information about the author of the post. |
| author.name | Required | Text | The name of the author of the post. |
| author.url | Recommended | URL | A link to a web page that uniquely identifies the author of the post, most likely a profile page of the forum. |
| datePublished | Required | DateTime | The date and time the post was made, in ISO 8601 format. |
| text | One of | Text | Any text in the post, if applicable. |
| image | One of | ImageObject or URL, can repeat | Inline images within the post. Do not use default, icon, placeholder, or author images. |
| video | One of | VideoObject, can repeat | Any inline videos within the post, if applicable. |
| comment | Recommended | Comment, can repeat | Comments about or responses to the post, in the order they appear on the page. |
| comment.author | Required | Person or Organization, can repeat | Information about the author of the comment. |
| comment.datePublished | Required | DateTime | The date and time the comment was made, in ISO 8601 format. |
| comment.text | One of | Text | Any text in the comment, if applicable. |
| comment.image | One of | ImageObject or URL, can repeat | Inline images within the comment, if applicable. |
| comment.video | One of | VideoObject, can repeat | Inline videos within the comment, if applicable. |
| comment.comment | Recommended | Comment, can repeat | Replies to this comment, if applicable. |
| comment.commentCount | Recommended | Integer | The number of replies to this comment, if applicable. |
| comment.creativeWorkStatus | Recommended | Text | Set creativeWorkStatus to Deleted when the comment was deleted but remains for context or threading. One of: Deleted. |
| comment.dateModified | Recommended | DateTime | When the comment was edited, in ISO 8601 format, if applicable. |
| comment.digitalSourceType | Recommended | Enum | Use when the comment was generated by an automated system (LLM or simpler bot). One of: TrainedAlgorithmicMediaDigitalSource, AlgorithmicMediaDigitalSource. |
| comment.interactionStatistic | Recommended | InteractionCounter, can repeat | User statistics applied to the comment, if applicable. |
| comment.sharedContent | Recommended | CreativeWork, WebPage, ImageObject, VideoObject, DiscussionForumPosting or Comment | The primary shared content in the comment, if applicable. |
| comment.url | Recommended | URL | The URL to this specific comment on the page, if applicable. |
| commentCount | Recommended | Integer | The number of comments about this post, if applicable. |
| creativeWorkStatus | Recommended | Text | Set creativeWorkStatus to Deleted when the post has been deleted but remains for context or threading. One of: Deleted. |
| dateModified | Recommended | DateTime | The date and time the post was edited, in ISO 8601 format, if applicable. |
| digitalSourceType | Recommended | Enum | Use when the post was generated by an automated system. One of: TrainedAlgorithmicMediaDigitalSource, AlgorithmicMediaDigitalSource. |
| headline | Recommended | Text | The title of the post. Don't duplicate or truncate the post text into a headline. |
| interactionStatistic | Recommended | InteractionCounter, can repeat | User statistics applied to the post, if applicable. |
| interactionStatistic.interactionType | Required | Enum | The subtype of Action the count refers to. One of: LikeAction, DislikeAction, ViewAction, CommentAction, ReplyAction, ShareAction. |
| interactionStatistic.userInteractionCount | Required | Integer | The number of times this interaction was performed. |
| isPartOf | Recommended | CreativeWork or URL | The primary source of the post if it occurs on a particular part of the website (for example a subforum). |
| mainEntityOfPage | Recommended | WebPage or URL | Use when the page is primarily about this single post. |
| sharedContent | Recommended | CreativeWork, WebPage, ImageObject, VideoObject, DiscussionForumPosting or Comment | The primary shared content in the post, if applicable. |
| url | Recommended | URL | The canonical URL of the discussion (first page URL for multi-page threads). |