Q&A Schema Framework
Use conditional-logic schema generation to keep Q&A markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralQ&A documentationUse only on pages with one question followed by its answers, where users can submit answers, such as a forum thread or a product support question. An FAQ written by the site, a product page with several questions, or a how-to guide or blog post that answers a question is not eligible.
Key Implementation Documentation Highlights
- Required on QAPage: mainEntity, the single Question on the page.
- Required on the Question: name (the short form of the question), answerCount (an integer, 0 is allowed) and at least one of acceptedAnswer or suggestedAnswer.
- Recommended on the Question: text (the long form), author (Person or Organization with name and a profile url), datePublished, dateModified, upvoteCount, comment, commentCount, image, video and digitalSourceType.
- Required on every Answer: the full text. Recommended: url (strongly recommended, a direct link such as a #fragment), author, datePublished, dateModified, upvoteCount (upvotes minus downvotes), comment, commentCount, image, video and digitalSourceType.
- acceptedAnswer marks answers accepted by the question author or moderators; suggestedAnswer marks the rest. Each Comment requires text and takes the same recommended fields; use comments for clarifications, not Answer markup.
- Dates are ISO 8601 DateTime values. digitalSourceType is only for AI-generated posts:
TrainedAlgorithmicMediaDigitalSourceorAlgorithmicMediaDigitalSource. - Use the Rich Results Test and URL Inspection to validate rendering, eligibility, and crawlability, then monitor the Q&A report in Search Console.
Rules the framework enforces before it emits markup:
- Exactly one Question per page, and users must be able to submit answers; otherwise emit nothing.
- No accepted or suggested answer with full text: emit nothing, because one of the two is required.
- Text must be the entire question, answer, or comment. Truncated excerpts ending in an ellipsis are dropped.
- answerCount is never lower than the answers marked up, and each answer url must be unique; duplicate answer URLs suppress the markup.
- Dates that are not valid ISO 8601 DateTime values are dropped, and timezone offsets are kept as written.
Template Approach
const pageData = {
question: "How many teaspoons are in a cup?",
answerCount: 1,
answer: "There are 48 teaspoons in one US cup: 16 tablespoons times 3 teaspoons each.",
answerUrl: "https://www.example.com/forum/q/1042#answer-1"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "QAPage",
"mainEntity": {
"@type": "Question",
"name": pageData.question,
"answerCount": pageData.answerCount,
"acceptedAnswer": {
"@type": "Answer",
"text": pageData.answer,
"url": pageData.answerUrl
}
}
};
{
"@context": "https://schema.org",
"@type": "QAPage",
"mainEntity": {
"@type": "Question",
"name": "How many teaspoons are in a cup?",
"answerCount": 1,
"acceptedAnswer": {
"@type": "Answer",
"text": "There are 48 teaspoons in one US cup: 16 tablespoons times 3 teaspoons each.",
"url": "https://www.example.com/forum/q/1042#answer-1"
}
}
}
Conditional-logic Framework
function isAbsoluteUrl(value) {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:";
} catch {
return false;
}
}
const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/;
function toIsoDateTime(value) {
if (value instanceof Date) return isNaN(value) ? null : value.toISOString();
if (typeof value !== "string" || !ISO_DATETIME.test(value.trim())) return null;
// keep the original string so the timezone offset survives
return isNaN(Date.parse(value.trim())) ? null : value.trim();
}
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((item) => item !== undefined);
return items.length ? items : undefined;
}
if (value && typeof value === "object") {
const out = {};
for (const [key, item] of Object.entries(value)) {
const kept = compact(item);
if (kept !== undefined) out[key] = kept;
}
return Object.keys(out).length ? out : undefined;
}
if (value === null || value === undefined || value === "") return undefined;
return value;
}
const AI_SOURCE_TYPES = {
trained: "https://schema.org/TrainedAlgorithmicMediaDigitalSource",
algorithmic: "https://schema.org/AlgorithmicMediaDigitalSource"
};
function fullText(value) {
// Google wants the entire text, never a truncated excerpt
const text = typeof value === "string" ? value.trim() : "";
return text && !/(\.\.\.|…)$/.test(text) ? text : null;
}
function toCount(value) {
return Number.isInteger(value) && value >= 0 ? value : null;
}
function buildAuthor(author) {
if (!author || !author.name) return null;
return {
"@type": author.type === "Organization" ? "Organization" : "Person",
"name": author.name,
"url": isAbsoluteUrl(author.url) ? author.url : null
};
}
function buildVideo(video) {
// inline video: only emit when Google's Video basics are present
const uploadDate = toIsoDateTime(video.uploadDate);
if (!video.name || !isAbsoluteUrl(video.thumbnailUrl) || !uploadDate) return null;
return {
"@type": "VideoObject",
"name": video.name,
"description": video.description,
"thumbnailUrl": video.thumbnailUrl,
"uploadDate": uploadDate,
"contentUrl": isAbsoluteUrl(video.contentUrl) ? video.contentUrl : null
};
}
function buildPostFields(post) {
// fields shared by Question, Answer and Comment
const comments = (post.comments || []).map(buildComment).filter(Boolean);
return {
"author": buildAuthor(post.author),
"datePublished": toIsoDateTime(post.datePublished),
"dateModified": toIsoDateTime(post.dateModified),
"image": (post.images || []).filter(isAbsoluteUrl),
"video": (post.videos || []).map(buildVideo).filter(Boolean),
"digitalSourceType": AI_SOURCE_TYPES[post.aiGenerated] || null,
"comment": comments,
"commentCount": comments.length ? Math.max(toCount(post.commentCount) ?? 0, comments.length) : toCount(post.commentCount)
};
}
function buildComment(comment) {
const text = fullText(comment.text);
if (!text) return null; // Comment requires text
return { "@type": "Comment", "text": text, ...buildPostFields(comment) };
}
function buildAnswer(answer) {
const text = fullText(answer.text);
if (!text) return null; // Answer requires the full text
return {
"@type": "Answer",
"text": text,
...buildPostFields(answer),
"upvoteCount": Number.isInteger(answer.votes) ? answer.votes : null, // upvotes minus downvotes
"url": isAbsoluteUrl(answer.url) ? answer.url : null
};
}
function buildQAPageSchema(source) {
// page gates: indexable, self-canonical, visible, one question that users can answer
if (source.indexable === false) return null;
if (!isAbsoluteUrl(source.url)) return null;
if (source.canonicalUrl && source.canonicalUrl !== source.url) return null;
if (source.usersCanSubmitAnswers !== true) return null; // site-written FAQ content is not QAPage
const q = source.question;
if (!q || Array.isArray(q)) return null; // exactly one Question per page
const name = fullText(q.title);
if (!name) return null;
const accepted = (q.answers || []).filter((a) => a.accepted).map(buildAnswer).filter(Boolean);
const suggested = (q.answers || []).filter((a) => !a.accepted).map(buildAnswer).filter(Boolean);
const marked = accepted.length + suggested.length;
if (marked === 0) return null; // acceptedAnswer or suggestedAnswer is required
// each answer url must be unique, for example a #fragment per answer
const answerUrls = [...accepted, ...suggested].map((a) => a.url).filter(Boolean);
if (new Set(answerUrls).size !== answerUrls.length) return null;
// answerCount is required and can never be lower than the answers marked up
const answerCount = Math.max(toCount(q.answerCount) ?? 0, marked);
const schema = {
"@context": "https://schema.org",
"@type": "QAPage",
"mainEntity": {
"@type": "Question",
"name": name,
"text": fullText(q.body),
"answerCount": answerCount,
"upvoteCount": Number.isInteger(q.votes) ? q.votes : null,
...buildPostFields(q),
"acceptedAnswer": accepted,
"suggestedAnswer": suggested
}
};
return compact(schema);
}
// Usage:
// buildQAPageSchema({
// url: "https://www.example.com/forum/q/1042",
// canonicalUrl: "https://www.example.com/forum/q/1042",
// indexable: true,
// usersCanSubmitAnswers: true,
// question: {
// title: "How many teaspoons are in a cup?",
// body: "I am converting a recipe from metric. How many teaspoons are in one US cup?",
// answerCount: 2,
// votes: 26,
// author: { name: "Riya Shah", url: "https://www.example.com/users/riya-shah" },
// datePublished: "2026-03-02T09:14:00-05:00",
// dateModified: "2026-03-02T09:20:00-05:00",
// images: ["https://www.example.com/forum/q/1042/measuring-cups.jpg"],
// commentCount: 1,
// comments: [{ text: "Do you mean a US cup or a metric cup?", author: { name: "Tom Ellis", url: "https://www.example.com/users/tom-ellis" }, datePublished: "2026-03-02T09:31:00-05:00", dateModified: "2026-03-02T09:31:00-05:00" }],
// answers: [
// { accepted: true, text: "There are 48 teaspoons in one US cup: 16 tablespoons times 3 teaspoons each.", votes: 41, url: "https://www.example.com/forum/q/1042#answer-1", author: { name: "Dana Kim", url: "https://www.example.com/users/dana-kim" }, datePublished: "2026-03-02T10:05:00-05:00", dateModified: "2026-03-02T10:05:00-05:00", images: ["https://www.example.com/forum/q/1042/teaspoon-chart.png"], commentCount: 1, comments: [{ text: "Confirmed with my kitchen scale, thanks.", author: { name: "Riya Shah", url: "https://www.example.com/users/riya-shah" }, datePublished: "2026-03-02T12:02:00-05:00", dateModified: "2026-03-02T12:02:00-05:00" }] },
// { accepted: false, text: "A metric cup is 250 ml, which is about 50 metric teaspoons of 5 ml.", votes: 7, url: "https://www.example.com/forum/q/1042#answer-2", author: { name: "Luis Ortega", url: "https://www.example.com/users/luis-ortega" }, datePublished: "2026-03-02T11:40:00-05:00", dateModified: "2026-03-03T08:12:00-05:00", commentCount: 1, comments: [{ text: "Useful if the recipe is Australian.", author: { name: "Tom Ellis", url: "https://www.example.com/users/tom-ellis" }, datePublished: "2026-03-03T09:00:00-05:00", dateModified: "2026-03-03T09:00:00-05:00" }] }
// ]
// }
// });
import re
from datetime import datetime
from urllib.parse import urlparse
def is_absolute_url(value):
if not isinstance(value, str):
return False
parsed = urlparse(value)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
ISO_DATETIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$")
def to_iso_datetime(value):
if isinstance(value, datetime):
return value.isoformat()
if not isinstance(value, str) or not ISO_DATETIME.match(value.strip()):
return None
# keep the original string so the timezone offset survives
try:
datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
return value.strip()
def compact(value):
if isinstance(value, list):
items = [kept for kept in (compact(item) for item in value) if kept is not None]
return items or None
if isinstance(value, dict):
out = {}
for key, item in value.items():
kept = compact(item)
if kept is not None:
out[key] = kept
return out or None
if value is None or value == "":
return None
return value
AI_SOURCE_TYPES = {
"trained": "https://schema.org/TrainedAlgorithmicMediaDigitalSource",
"algorithmic": "https://schema.org/AlgorithmicMediaDigitalSource",
}
def full_text(value):
# Google wants the entire text, never a truncated excerpt
text = value.strip() if isinstance(value, str) else ""
return text if text and not re.search(r"(\.\.\.|…)$", text) else None
def to_count(value):
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
def is_int(value):
return isinstance(value, int) and not isinstance(value, bool)
def build_author(author):
if not author or not author.get("name"):
return None
return {
"@type": "Organization" if author.get("type") == "Organization" else "Person",
"name": author["name"],
"url": author.get("url") if is_absolute_url(author.get("url")) else None,
}
def build_video(video):
# inline video: only emit when Google's Video basics are present
upload_date = to_iso_datetime(video.get("uploadDate"))
if not video.get("name") or not is_absolute_url(video.get("thumbnailUrl")) or not upload_date:
return None
return {
"@type": "VideoObject",
"name": video["name"],
"description": video.get("description"),
"thumbnailUrl": video["thumbnailUrl"],
"uploadDate": upload_date,
"contentUrl": video.get("contentUrl") if is_absolute_url(video.get("contentUrl")) else None,
}
def build_post_fields(post):
# fields shared by Question, Answer and Comment
comments = [c for c in (build_comment(c) for c in post.get("comments") or []) if c]
count = to_count(post.get("commentCount"))
return {
"author": build_author(post.get("author")),
"datePublished": to_iso_datetime(post.get("datePublished")),
"dateModified": to_iso_datetime(post.get("dateModified")),
"image": [u for u in post.get("images") or [] if is_absolute_url(u)],
"video": [v for v in (build_video(v) for v in post.get("videos") or []) if v],
"digitalSourceType": AI_SOURCE_TYPES.get(post.get("aiGenerated")),
"comment": comments,
"commentCount": max(count or 0, len(comments)) if comments else count,
}
def build_comment(comment):
text = full_text(comment.get("text"))
if not text:
return None # Comment requires text
return {"@type": "Comment", "text": text, **build_post_fields(comment)}
def build_answer(answer):
text = full_text(answer.get("text"))
if not text:
return None # Answer requires the full text
return {
"@type": "Answer",
"text": text,
**build_post_fields(answer),
"upvoteCount": answer.get("votes") if is_int(answer.get("votes")) else None, # upvotes minus downvotes
"url": answer.get("url") if is_absolute_url(answer.get("url")) else None,
}
def build_qa_page_schema(source):
# page gates: indexable, self-canonical, visible, one question that users can answer
if source.get("indexable") is False:
return None
if not is_absolute_url(source.get("url")):
return None
if source.get("canonicalUrl") and source["canonicalUrl"] != source["url"]:
return None
if source.get("usersCanSubmitAnswers") is not True:
return None # site-written FAQ content is not QAPage
q = source.get("question")
if not q or isinstance(q, list):
return None # exactly one Question per page
name = full_text(q.get("title"))
if not name:
return None
answers = q.get("answers") or []
accepted = [a for a in (build_answer(a) for a in answers if a.get("accepted")) if a]
suggested = [a for a in (build_answer(a) for a in answers if not a.get("accepted")) if a]
marked = len(accepted) + len(suggested)
if marked == 0:
return None # acceptedAnswer or suggestedAnswer is required
# each answer url must be unique, for example a #fragment per answer
answer_urls = [a["url"] for a in accepted + suggested if a.get("url")]
if len(set(answer_urls)) != len(answer_urls):
return None
# answerCount is required and can never be lower than the answers marked up
answer_count = max(to_count(q.get("answerCount")) or 0, marked)
schema = {
"@context": "https://schema.org",
"@type": "QAPage",
"mainEntity": {
"@type": "Question",
"name": name,
"text": full_text(q.get("body")),
"answerCount": answer_count,
"upvoteCount": q.get("votes") if is_int(q.get("votes")) else None,
**build_post_fields(q),
"acceptedAnswer": accepted,
"suggestedAnswer": suggested,
},
}
return compact(schema)
# Usage:
# build_qa_page_schema({
# "url": "https://www.example.com/forum/q/1042",
# "canonicalUrl": "https://www.example.com/forum/q/1042",
# "indexable": True,
# "usersCanSubmitAnswers": True,
# "question": {
# "title": "How many teaspoons are in a cup?",
# "body": "I am converting a recipe from metric. How many teaspoons are in one US cup?",
# "answerCount": 2,
# "votes": 26,
# "author": {"name": "Riya Shah", "url": "https://www.example.com/users/riya-shah"},
# "datePublished": "2026-03-02T09:14:00-05:00",
# "dateModified": "2026-03-02T09:20:00-05:00",
# "images": ["https://www.example.com/forum/q/1042/measuring-cups.jpg"],
# "commentCount": 1,
# "comments": [{"text": "Do you mean a US cup or a metric cup?", "author": {"name": "Tom Ellis", "url": "https://www.example.com/users/tom-ellis"}, "datePublished": "2026-03-02T09:31:00-05:00", "dateModified": "2026-03-02T09:31:00-05:00"}],
# "answers": [
# {"accepted": True, "text": "There are 48 teaspoons in one US cup: 16 tablespoons times 3 teaspoons each.", "votes": 41, "url": "https://www.example.com/forum/q/1042#answer-1", "author": {"name": "Dana Kim", "url": "https://www.example.com/users/dana-kim"}, "datePublished": "2026-03-02T10:05:00-05:00", "dateModified": "2026-03-02T10:05:00-05:00", "images": ["https://www.example.com/forum/q/1042/teaspoon-chart.png"], "commentCount": 1, "comments": [{"text": "Confirmed with my kitchen scale, thanks.", "author": {"name": "Riya Shah", "url": "https://www.example.com/users/riya-shah"}, "datePublished": "2026-03-02T12:02:00-05:00", "dateModified": "2026-03-02T12:02:00-05:00"}]},
# {"accepted": False, "text": "A metric cup is 250 ml, which is about 50 metric teaspoons of 5 ml.", "votes": 7, "url": "https://www.example.com/forum/q/1042#answer-2", "author": {"name": "Luis Ortega", "url": "https://www.example.com/users/luis-ortega"}, "datePublished": "2026-03-02T11:40:00-05:00", "dateModified": "2026-03-03T08:12:00-05:00", "commentCount": 1, "comments": [{"text": "Useful if the recipe is Australian.", "author": {"name": "Tom Ellis", "url": "https://www.example.com/users/tom-ellis"}, "datePublished": "2026-03-03T09:00:00-05:00", "dateModified": "2026-03-03T09:00:00-05:00"}]},
# ],
# },
# })
{
"@context": "https://schema.org",
"@type": "QAPage",
"mainEntity": {
"@type": "Question",
"name": "How many teaspoons are in a cup?",
"text": "I am converting a recipe from metric. How many teaspoons are in one US cup?",
"answerCount": 2,
"upvoteCount": 26,
"author": {
"@type": "Person",
"name": "Riya Shah",
"url": "https://www.example.com/users/riya-shah"
},
"datePublished": "2026-03-02T09:14:00-05:00",
"dateModified": "2026-03-02T09:20:00-05:00",
"image": [
"https://www.example.com/forum/q/1042/measuring-cups.jpg"
],
"comment": [
{
"@type": "Comment",
"text": "Do you mean a US cup or a metric cup?",
"author": {
"@type": "Person",
"name": "Tom Ellis",
"url": "https://www.example.com/users/tom-ellis"
},
"datePublished": "2026-03-02T09:31:00-05:00",
"dateModified": "2026-03-02T09:31:00-05:00"
}
],
"commentCount": 1,
"acceptedAnswer": [
{
"@type": "Answer",
"text": "There are 48 teaspoons in one US cup: 16 tablespoons times 3 teaspoons each.",
"author": {
"@type": "Person",
"name": "Dana Kim",
"url": "https://www.example.com/users/dana-kim"
},
"datePublished": "2026-03-02T10:05:00-05:00",
"dateModified": "2026-03-02T10:05:00-05:00",
"image": [
"https://www.example.com/forum/q/1042/teaspoon-chart.png"
],
"comment": [
{
"@type": "Comment",
"text": "Confirmed with my kitchen scale, thanks.",
"author": {
"@type": "Person",
"name": "Riya Shah",
"url": "https://www.example.com/users/riya-shah"
},
"datePublished": "2026-03-02T12:02:00-05:00",
"dateModified": "2026-03-02T12:02:00-05:00"
}
],
"commentCount": 1,
"upvoteCount": 41,
"url": "https://www.example.com/forum/q/1042#answer-1"
}
],
"suggestedAnswer": [
{
"@type": "Answer",
"text": "A metric cup is 250 ml, which is about 50 metric teaspoons of 5 ml.",
"author": {
"@type": "Person",
"name": "Luis Ortega",
"url": "https://www.example.com/users/luis-ortega"
},
"datePublished": "2026-03-02T11:40:00-05:00",
"dateModified": "2026-03-03T08:12:00-05:00",
"comment": [
{
"@type": "Comment",
"text": "Useful if the recipe is Australian.",
"author": {
"@type": "Person",
"name": "Tom Ellis",
"url": "https://www.example.com/users/tom-ellis"
},
"datePublished": "2026-03-03T09:00:00-05:00",
"dateModified": "2026-03-03T09:00:00-05:00"
}
],
"commentCount": 1,
"upvoteCount": 7,
"url": "https://www.example.com/forum/q/1042#answer-2"
}
]
}
}
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
- Splits accepted and suggested answers from moderation state, so the markup follows the thread as answers are accepted or removed.
- Recomputes answerCount and commentCount from the thread, so counts never fall below what is marked up.
- Applies one Answer and Comment builder at every nesting level, so the Comment requirement (text) holds for nested comments too.
- Suppresses markup on site-written FAQ pages and multi-question pages, which are not eligible for Q&A.
Property Reference
What Google's Q&A 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: In mainEntity: question requires acceptedAnswer or suggestedAnswer.
| Property | Expects | Notes | |
|---|---|---|---|
| mainEntity | Required | Question | QAPage requires mainEntity Question. |
| mainEntity.name | Required | Text | Question requires name: the short form of the question. |
| mainEntity.answerCount | Required | Integer | Question requires answerCount (may be 0) |
| mainEntity.acceptedAnswer | One of | Answer, can repeat | Or suggestedAnswer. |
| mainEntity.suggestedAnswer | One of | Answer, can repeat | Or acceptedAnswer. |
| mainEntity.text | Recommended | Text | The long form of the question. |
| mainEntity.author | Recommended | Person or Organization | Author. |
| mainEntity.datePublished | Recommended | DateTime | DatePublished (ISO 8601) |
| mainEntity.dateModified | Recommended | DateTime | DateModified (ISO 8601) |
| mainEntity.comment | Recommended | Comment, can repeat | Comments as Comment. |
| mainEntity.commentCount | Recommended | Integer | CommentCount. |
| mainEntity.image | Recommended | URL or ImageObject, can repeat | Images of the question. |
| mainEntity.video | Recommended | VideoObject, can repeat | Videos of the question. |
| mainEntity.digitalSourceType | Recommended | Enum | DigitalSourceType must be TrainedAlgorithmicMediaDigitalSource or AlgorithmicMediaDigitalSource. One of: TrainedAlgorithmicMediaDigitalSource, AlgorithmicMediaDigitalSource. |
| mainEntity.upvoteCount | Recommended | Integer | UpvoteCount. |