Article Schema Framework
Use conditional-logic schema generation to keep Article markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralArticle documentationUse for news, blog, and sports article pages where the visible content is a complete article with a clear byline, visible headline, primary image, and publication timestamps. The markup helps Google show better title text, images, and date information for the article.
Key Implementation Documentation Highlights
- Google lists no required properties for Article. Add the properties that apply to your content.
- Recommended properties: author (with author.name and author.url), datePublished, dateModified, headline, and image.
- Supported types: Article, NewsArticle, and BlogPosting.
- author: include every author, each in its own author entry, typed as Person or Organization. author.name holds only the name: no job titles, honorifics, publisher names, or prefixes such as "posted by". Use author.url (or sameAs) to point at a page that uniquely identifies the author.
- datePublished and dateModified use ISO 8601. Google recommends timezone information; without it, Google defaults to the timezone used by Googlebot.
- headline: keep it concise, since long titles may be truncated on some devices.
- image: crawlable, indexable, representative of the content, and in a format Google Images supports. For best results provide multiple high-resolution images (at least 50,000 pixels, width times height) in 16x9, 4x3, and 1x1 aspect ratios.
- For multi-part articles, rel=canonical should point at each individual page or a view-all page, not at page 1. For subscription or registration content, consider paywalled content markup.
- Use the Rich Results Test and URL Inspection to validate rendering, eligibility, and crawlability.
Article Schema Decision Gates
Before emitting Article schema, the page should pass a few content and indexability checks. These gates prevent a reusable framework from marking up thin, utility, category, or non-indexable URLs as articles.
- Emit Article only when the page has a visible article body and a visible headline. Google requires no specific property, so the gate is about the page, not about filling fields.
- Suppress Article schema when the URL is noindex, blocked from crawling, canonicalized to another page, or missing a stable canonical URL.
- Include image only when the asset is crawlable, absolute, representative of the article, and not known to be under 50,000 pixels.
- Normalize dates to ISO 8601 with a timezone offset and never emit a dateModified earlier than datePublished.
- Use headline for the article title. It is the title property Google documents for Article.
Article Subtype Selection Logic
A production framework should choose the most specific type instead of defaulting every editorial URL to Article. The type decision should be deterministic and based on the page template, content purpose, and publisher rules.
- Use NewsArticle for timely reporting, announcements, and newsroom-style content where freshness and publication chronology matter.
- Use BlogPosting for opinion, analysis, tutorials, and blog-style resources that live in a blog or insights section.
- Use Article as the fallback when the page is editorial but does not cleanly match a narrower subtype.
- Avoid Article markup on landing pages, service pages, tag pages, author archives, faceted URLs, and thin excerpts.
Data Sourcing Model
Strong Article schema usually fails because fields are sourced from inconsistent places. Treat schema generation as a small data pipeline rather than a static snippet.
- Headline should come from the canonical H1 or editorial title field.
- Authors should come from the byline data model as a list, one record per author, with a profile URL each. Do not fall back to the brand unless the visible byline is the organization, and then type it as Organization.
- Author names should be stored without honorifics, job titles, or "By" prefixes, so schema output never needs string surgery to stay clean.
- Dates should come from publication and modified timestamps in the CMS, stored with an offset, then formatted consistently during build or render.
- Images should come from the primary article image and its 16x9, 4x3, and 1x1 crops, with known width and height so the 50,000 pixel floor can be checked.
Template Approach
const pageData = {
title: "Sample Article Entity",
url: "https://www.example.com/article/sample",
publishDate: "2026-01-20T09:00:00-05:00",
image: "https://www.example.com/assets/article.jpg",
authorName: "Example Author"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Article",
"headline": pageData.title,
"url": pageData.url,
"datePublished": pageData.publishDate,
"image": pageData.image,
"author": { "@type": "Person", "name": pageData.authorName }
};
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Sample Article Entity",
"url": "https://www.example.com/article/sample",
"datePublished": "2026-01-20T09:00:00-05:00",
"image": "https://www.example.com/assets/article.jpg",
"author": {
"@type": "Person",
"name": "Example Author"
}
}
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 HONORIFIC = /^(dr|mr|mrs|ms|prof)\.?\s+/i;
const BYLINE_PREFIX = /^(posted by|written by|by)\s+/i;
const MIN_IMAGE_PIXELS = 50000;
function chooseArticleType(source) {
if (source.template === "news") return "NewsArticle";
if (source.template === "blog") return "BlogPosting";
return "Article";
}
// author.name holds only the name: strip byline prefixes and honorifics,
// and reject strings that join several people or carry a job title.
function cleanAuthorName(name, publisherName) {
if (typeof name !== "string") return null;
const clean = name.trim().replace(BYLINE_PREFIX, "").replace(HONORIFIC, "").trim();
if (!clean || /,|\||\s(and|&)\s/i.test(clean)) return null;
if (publisherName && clean.toLowerCase().includes(publisherName.toLowerCase())) return null;
return clean;
}
function buildAuthor(author, publisherName) {
const name = cleanAuthorName(author.name, publisherName);
if (!name) return null;
return {
"@type": author.isOrganization ? "Organization" : "Person",
"name": name,
"url": isAbsoluteUrl(author.url) ? author.url : null
};
}
// Keep crawlable absolute images; drop any image known to be under 50K pixels.
function buildImages(images) {
return (images || [])
.filter((img) => isAbsoluteUrl(img.url))
.filter((img) => !(img.width && img.height) || img.width * img.height >= MIN_IMAGE_PIXELS)
.map((img) => ({ "@type": "ImageObject", "url": img.url, "width": img.width, "height": img.height }));
}
function buildArticleSchema(source) {
if (!passesPageGates(source)) return null;
// Google lists no required Article properties. This framework still
// refuses to emit a node without the visible headline.
if (!source.headline) return null;
const datePublished = toIsoDateTime(source.datePublished);
let dateModified = toIsoDateTime(source.dateModified);
if (datePublished && dateModified && Date.parse(dateModified) < Date.parse(datePublished)) {
dateModified = null; // never emit a modified date earlier than the publish date
}
const schema = {
"@context": "https://schema.org",
"@type": chooseArticleType(source),
"headline": source.headline.trim(),
"image": buildImages(source.images),
"datePublished": datePublished,
"dateModified": dateModified,
// Each author in its own entry, never one joined string.
"author": (source.authors || []).map((a) => buildAuthor(a, source.publisherName)).filter(Boolean)
};
return compact(schema) || null;
}
// Usage
// const source = {
// "isIndexable": true,
// "isContentVisible": true,
// "url": "https://www.example.com/blog/crawl-budget-audit",
// "canonicalUrl": "https://www.example.com/blog/crawl-budget-audit",
// "template": "blog",
// "headline": "How to Run a Crawl Budget Audit",
// "datePublished": "2026-09-14T08:00:00-04:00",
// "dateModified": "2026-10-02T16:30:00-04:00",
// "publisherName": "Example Media",
// "authors": [
// {
// "name": "By Dana Reyes",
// "url": "https://www.example.com/authors/dana-reyes"
// },
// {
// "name": "Dr. Sam Okafor",
// "url": "https://www.example.com/authors/sam-okafor"
// }
// ],
// "images": [
// {
// "url": "https://www.example.com/img/crawl-audit-16x9.jpg",
// "width": 1600,
// "height": 900
// },
// {
// "url": "https://www.example.com/img/crawl-audit-4x3.jpg",
// "width": 1200,
// "height": 900
// },
// {
// "url": "https://www.example.com/img/crawl-audit-1x1.jpg",
// "width": 1000,
// "height": 1000
// }
// ]
// };
// const jsonLd = buildArticleSchema(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")
HONORIFIC = re.compile(r"^(dr|mr|mrs|ms|prof)\.?\s+", re.I)
BYLINE_PREFIX = re.compile(r"^(posted by|written by|by)\s+", re.I)
MIN_IMAGE_PIXELS = 50000
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()
def choose_article_type(source):
if source.get("template") == "news":
return "NewsArticle"
if source.get("template") == "blog":
return "BlogPosting"
return "Article"
# author.name holds only the name: strip byline prefixes and honorifics,
# and reject strings that join several people or carry a job title.
def clean_author_name(name, publisher_name):
if not isinstance(name, str):
return None
clean = HONORIFIC.sub("", BYLINE_PREFIX.sub("", name.strip())).strip()
if not clean or re.search(r",|\||\s(and|&)\s", clean, re.I):
return None
if publisher_name and publisher_name.lower() in clean.lower():
return None
return clean
def build_author(author, publisher_name):
name = clean_author_name(author.get("name"), publisher_name)
if not name:
return None
return {
"@type": "Organization" if author.get("isOrganization") else "Person",
"name": name,
"url": author.get("url") if is_absolute_url(author.get("url")) else None,
}
# Keep crawlable absolute images; drop any image known to be under 50K pixels.
def build_images(images):
out = []
for img in images or []:
if not is_absolute_url(img.get("url")):
continue
w, h = img.get("width"), img.get("height")
if w and h and w * h < MIN_IMAGE_PIXELS:
continue
out.append({"@type": "ImageObject", "url": img["url"], "width": w, "height": h})
return out
def build_article_schema(source):
if not passes_page_gates(source):
return None
# Google lists no required Article properties. This framework still
# refuses to emit a node without the visible headline.
if not source.get("headline"):
return None
date_published = to_iso_date_time(source.get("datePublished"))
date_modified = to_iso_date_time(source.get("dateModified"))
if date_published and date_modified and to_timestamp(date_modified) < to_timestamp(date_published):
date_modified = None # never emit a modified date earlier than the publish date
authors = [build_author(a, source.get("publisherName")) for a in source.get("authors") or []]
schema = {
"@context": "https://schema.org",
"@type": choose_article_type(source),
"headline": source["headline"].strip(),
"image": build_images(source.get("images")),
"datePublished": date_published,
"dateModified": date_modified,
# Each author in its own entry, never one joined string.
"author": [a for a in authors if a],
}
return compact(schema)
# Usage: json_ld = build_article_schema(source) # same source dict as the JS example; None means emit nothing
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "How to Run a Crawl Budget Audit",
"image": [
{
"@type": "ImageObject",
"url": "https://www.example.com/img/crawl-audit-16x9.jpg",
"width": 1600,
"height": 900
},
{
"@type": "ImageObject",
"url": "https://www.example.com/img/crawl-audit-4x3.jpg",
"width": 1200,
"height": 900
},
{
"@type": "ImageObject",
"url": "https://www.example.com/img/crawl-audit-1x1.jpg",
"width": 1000,
"height": 1000
}
],
"datePublished": "2026-09-14T08:00:00-04:00",
"dateModified": "2026-10-02T16:30:00-04:00",
"author": [
{
"@type": "Person",
"name": "Dana Reyes",
"url": "https://www.example.com/authors/dana-reyes"
},
{
"@type": "Person",
"name": "Sam Okafor",
"url": "https://www.example.com/authors/sam-okafor"
}
]
}
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
- Prevents emitting empty, null, or stale fields, such as a date without a timezone or an author string that joins two people.
- Supports multiple page states (for example: no author yet, no image yet, draft article, canonicalized syndicated article) without duplicate templates.
- Lets engineering teams centralize schema policy checks, such as author name cleaning and image size floors, in reusable code paths.
- Makes large-scale schema maintenance safer when content models evolve over time.
Common Failure Modes To Monitor
- Article schema emits on a URL that is canonicalized elsewhere, or on page 2 of a multi-part article whose canonical points at page 1.
- dateModified is older than datePublished, or dates lose their timezone offset after a CMS migration.
- author.name carries a job title, honorific, publisher name, or several names in one string, or the author is a bare string with no @type.
- Authors have no url or sameAs, so Google cannot disambiguate them.
- The image URL is blocked, redirected, a data URI, under 50,000 pixels, or replaced by a temporary CDN URL.
Deployment Checklist
- Compare every schema value against visible page content before release, especially the byline and dates.
- Validate the final rendered HTML, not only the source template, because client-side rendering and build steps can change the emitted JSON-LD.
- Log suppressed Article schema cases so teams can distinguish intentional non-emission from generation failures.
- Re-run validation when article templates, author models, image handling, canonical logic, or publication-date fields change.
- Track warning counts by article subtype so NewsArticle, BlogPosting, and generic Article issues can be diagnosed separately.
Property Reference
What Google's Article documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
| Property | Expects | Notes | |
|---|---|---|---|
| author | Recommended | Person or Organization, can repeat | The author of the article (Person or Organization). List each author in their own author field. |
| author.name | Recommended | Text | The name of the author. Only the name, no titles, honorifics or publisher names. |
| author.url | Recommended | URL | A link to a web page that uniquely identifies the author of the article, for example the author's social media page, an about me page, or a bio page. |
| dateModified | Recommended | DateTime | The date and time the article was most recently modified, in ISO 8601 format. |
| datePublished | Recommended | DateTime | The date and time the article was first published, in ISO 8601 format, ideally with timezone information. |
| headline | Recommended | Text | The title of the article. Keep it concise, long titles may be truncated on some devices. |
| image | Recommended | URL or ImageObject, can repeat | The URL to an image that is representative of the article. Provide multiple high-resolution images (at least 50K pixels) in 16x9, 4x3 and 1x1. Aspect ratios: 16x9, 4x3, 1x1. |