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 documentation

Use 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: TrainedAlgorithmicMediaDigitalSource or AlgorithmicMediaDigitalSource.
  • 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
    }
  }
};

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" }] }
//     ]
//   }
// });

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.

Q&A properties in Google's documentation
PropertyGoogleExpectsNotes
mainEntityRequiredQuestionQAPage requires mainEntity Question.
mainEntity.nameRequiredTextQuestion requires name: the short form of the question.
mainEntity.answerCountRequiredIntegerQuestion requires answerCount (may be 0)
mainEntity.acceptedAnswerOne ofAnswer, can repeatOr suggestedAnswer.
mainEntity.suggestedAnswerOne ofAnswer, can repeatOr acceptedAnswer.
mainEntity.textRecommendedTextThe long form of the question.
mainEntity.authorRecommendedPerson or OrganizationAuthor.
mainEntity.datePublishedRecommendedDateTimeDatePublished (ISO 8601)
mainEntity.dateModifiedRecommendedDateTimeDateModified (ISO 8601)
mainEntity.commentRecommendedComment, can repeatComments as Comment.
mainEntity.commentCountRecommendedIntegerCommentCount.
mainEntity.imageRecommendedURL or ImageObject, can repeatImages of the question.
mainEntity.videoRecommendedVideoObject, can repeatVideos of the question.
mainEntity.digitalSourceTypeRecommendedEnumDigitalSourceType must be TrainedAlgorithmicMediaDigitalSource or AlgorithmicMediaDigitalSource. One of: TrainedAlgorithmicMediaDigitalSource, AlgorithmicMediaDigitalSource.
mainEntity.upvoteCountRecommendedIntegerUpvoteCount.

Explore Other Schema Framework Pages

Recipe Use for recipe pages with complete ingredients, instructions, and preparation details. Recipe Open Return Policy Use to publish a merchant return policy once at the organization level, with offer-level overrides. MerchantReturnPolicy Open Review Snippet Use when a page publishes genuine reviews tied to a clearly identified product, service, or entity. Review Open Shipping Policy Use to publish shipping rates, delivery times and destinations once at the organization level. ShippingService Open Site Name Use on the home page so Google shows the preferred site name in search results. WebSite Open Retired Sitelinks Search Box Use on sites with a functioning internal search endpoint that can accept URL query parameters. WebSite Open Software App (Beta) Use for software product pages that describe platform support and application category clearly. SoftwareApplication Open Limited Speakable Use for news-style pages where specific text segments are appropriate for text-to-speech playback. SpeakableSpecification Open Subscription and Paywalled Content Use for pages with restricted sections so Google can distinguish free previews from protected content. CreativeWork Open Limited Vacation Rental Use for rental property detail pages where booking-relevant amenities and location signals are explicit. VacationRental Open Retired Vehicle Listing Use for inventory pages selling vehicles with standardized listing fields and offer information. Car Open Video Use for pages where a primary video is embedded and key media metadata is visible on-page. VideoObject Open Article Use for editorial pages where the visible content is a complete article with a clear byline and publication timestamps. Article Open Limited Book Actions Use when your page helps users read, preview, or purchase books through clearly described actions. Book Open Breadcrumb Use on pages that belong to a clear hierarchy and display navigational trail links users can follow. BreadcrumbList Open Carousel Use when a page lists multiple related entities in a scannable sequence that can be surfaced as cards. ItemList Open Course Use for educational offerings where curriculum details, provider identity, and delivery context are explicit. Course Open Dataset Use for dataset landing pages so datasets are discoverable in Google Dataset Search with license, creator and download details. Dataset Open Discussion Forum Use for forum and community threads where a primary post and subsequent replies are publicly visible. DiscussionForumPosting Open Limited Education Q&A Use for educational question-answer pages where a single canonical answer and supporting context are visible. Quiz Open Employer Aggregate Rating Use on pages that publish employer rating aggregates from a clear and policy-compliant review source. EmployerAggregateRating Open Retired Estimated Salary Use for compensation insight pages that present transparent salary ranges for specific occupations and locations. Occupation Open Event Use for upcoming events with a real-world or virtual occurrence, schedule, and location context. Event Open Retired Fact Check Use for fact-checking pages that evaluate a specific claim with a transparent rating methodology. ClaimReview Open Retired Home Activities Use for virtual classes and activities where participation details and timing are clearly listed. Event Open Retired How-To Use for instructional content that teaches a process through ordered steps and actionable detail. HowTo Open Image Metadata Use on image pages and image hosts that expose ownership and licensing information to search engines. ImageObject Open Job Posting Use for active job listings that include role details, hiring entity, and location/salary context. JobPosting Open Retired Learning Video Use for educational videos where the learning objective and pedagogical structure are explicit. LearningResource Open Local Business Use for physical businesses serving local customers with publicly verifiable contact and location details. LocalBusiness Open Loyalty Program Use to publish a loyalty program and its tiers once at the organization level, so member prices and member shipping can reference them. MemberProgram Open Math Solvers Use for pages that solve mathematical expressions and present step-by-step computational guidance. MathSolver Open Merchant Listing Use on pages where a product can be bought, with price, availability, shipping and return details. Product Open Limited Movie Use for movie detail pages with canonical film information and transparent metadata attribution. Movie Open Organization Use for official organization pages to clarify brand identity and authoritative entity signals. Organization Open Retired Practice Problems Use for educational practice content that presents solvable problems and answer validation structure. Quiz Open Product Snippet Use for product pages where pricing, availability, and product identity are visible to users. Product Open Product Variants Use when one product comes in variants such as size or color, grouped with ProductGroup. ProductGroup Open Profile Page Use for creator or contributor profile pages where identity attributes and ownership are explicit. ProfilePage Open

Implement Q&A Schema Frameworks Correctly

Get implementation support for conditional schema architecture, validation, and deployment governance.