Profile Page Schema Framework
Use conditional-logic schema generation to keep Profile Page markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralProfile Page documentationUse on pages whose primary focus is a single person or organization affiliated with the site, on sites where creators share first-hand perspectives: forum and social media user profiles, author pages on news sites, "About Me" pages on blogs, and employee pages on company sites. Google uses this markup in features such as Discussions and Forums. A store homepage or a review site page about an unaffiliated organization does not qualify.
Key Implementation Documentation Highlights
- Required on ProfilePage: mainEntity, the Person or Organization the page is about, with mainEntity.name.
- Recommended on the page: dateCreated and dateModified (ISO 8601; dateModified reflects human-edited profile changes).
- Recommended on mainEntity: alternateName, description (byline or credential), identifier (a stable internal ID), image, sameAs (other profiles or home pages), interactionStatistic, and agentInteractionStatistic.
- Use the real name in name and the handle in alternateName; a handle in name is fine when it is the only identifier.
- interactionStatistic counts what happens to the entity on this platform only: FollowAction (followers), LikeAction (likes), BefriendAction (two-way relationships). agentInteractionStatistic counts what the entity does: FollowAction (accounts followed), LikeAction (likes given), WriteAction (posts), ShareAction (reshares).
- image must be a real profile image: no default, icon, or placeholder images. Images must be crawlable, indexable, and high resolution.
- Use the Rich Results Test and URL Inspection to validate rendering, eligibility, and crawlability.
Rules the framework enforces before it emits a profile:
- mainEntity is always typed Person or Organization and always has a name.
- Each statistic maps to its own allowed action list, so WriteAction or ShareAction never lands in interactionStatistic and BefriendAction never lands in agentInteractionStatistic.
- Image URLs that look like default avatars or placeholders are dropped rather than emitted.
- A dateModified earlier than dateCreated is dropped.
Template Approach
const pageData = {
name: "Priya Raman",
handle: "priya_r",
avatar: "https://forum.example.com/uploads/avatars/priya_r-400.jpg"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "ProfilePage",
"mainEntity": {
"@type": "Person",
"name": pageData.name,
"alternateName": pageData.handle,
"image": pageData.avatar
}
};
{
"@context": "https://schema.org",
"@type": "ProfilePage",
"mainEntity": {
"@type": "Person",
"name": "Priya Raman",
"alternateName": "priya_r",
"image": "https://forum.example.com/uploads/avatars/priya_r-400.jpg"
}
}
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;
}
// Stats about the profile entity itself, counted on this platform only.
const PROFILE_STATS = {
followers: "https://schema.org/FollowAction",
likes: "https://schema.org/LikeAction",
friends: "https://schema.org/BefriendAction"
};
// Stats about what the entity does: following, liking, posting, resharing.
const AGENT_STATS = {
following: "https://schema.org/FollowAction",
likesGiven: "https://schema.org/LikeAction",
posts: "https://schema.org/WriteAction",
reshares: "https://schema.org/ShareAction"
};
const PLACEHOLDER_IMAGE = /default|placeholder|avatar-blank|blank-avatar|gravatar\.com\/avatar\/[^?]*\?.*d=/i;
function chooseProfileEntityType(source) {
return source.isOrganization ? "Organization" : "Person";
}
function buildCounters(stats, allowed) {
return Object.entries(stats || {})
.filter(([key, count]) => allowed[key] && Number.isInteger(count) && count >= 0)
.map(([key, count]) => ({ "@type": "InteractionCounter", "interactionType": allowed[key], "userInteractionCount": count }));
}
function buildProfilePageSchema(source) {
if (!passesPageGates(source)) return null;
// The page must be about one person or organization affiliated with the site.
if (!source.isAffiliatedWithSite) return null;
// Prefer the real name; a handle in name is fine when it is the only identifier.
const name = (source.realName || source.handle || "").trim();
if (!name) return null;
const alternateName = source.realName && source.handle ? source.handle : null;
const image = isAbsoluteUrl(source.imageUrl) && !PLACEHOLDER_IMAGE.test(source.imageUrl) ? source.imageUrl : null;
const dateCreated = toIsoDateTime(source.dateCreated);
let dateModified = toIsoDateTime(source.dateModified);
if (dateCreated && dateModified && Date.parse(dateModified) < Date.parse(dateCreated)) dateModified = null;
return compact({
"@context": "https://schema.org",
"@type": "ProfilePage",
"dateCreated": dateCreated,
"dateModified": dateModified,
"mainEntity": {
"@type": chooseProfileEntityType(source),
"name": name,
"alternateName": alternateName,
"identifier": source.internalId != null ? String(source.internalId) : null,
"description": source.byline,
"image": image,
"sameAs": (source.externalProfiles || []).filter(isAbsoluteUrl),
"interactionStatistic": buildCounters(source.profileStats, PROFILE_STATS),
"agentInteractionStatistic": buildCounters(source.activityStats, AGENT_STATS)
}
}) || null;
}
// Usage
// const source = {
// "isIndexable": true,
// "isContentVisible": true,
// "isAffiliatedWithSite": true,
// "url": "https://forum.example.com/u/priya_r",
// "canonicalUrl": "https://forum.example.com/u/priya_r",
// "realName": "Priya Raman",
// "handle": "priya_r",
// "internalId": 48213,
// "byline": "Technical SEO lead, writes about rendering and crawl budget.",
// "imageUrl": "https://forum.example.com/uploads/avatars/priya_r-400.jpg",
// "externalProfiles": [
// "https://www.linkedin.com/in/example-priya",
// "https://priya.example.net"
// ],
// "profileStats": {
// "followers": 1820,
// "likes": 5310
// },
// "activityStats": {
// "following": 214,
// "posts": 642
// },
// "dateCreated": "2024-03-11T09:12:00Z",
// "dateModified": "2026-09-30T18:45:00Z"
// };
// const jsonLd = buildProfilePageSchema(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")
# Stats about the profile entity itself, counted on this platform only.
PROFILE_STATS = {
"followers": "https://schema.org/FollowAction",
"likes": "https://schema.org/LikeAction",
"friends": "https://schema.org/BefriendAction",
}
# Stats about what the entity does: following, liking, posting, resharing.
AGENT_STATS = {
"following": "https://schema.org/FollowAction",
"likesGiven": "https://schema.org/LikeAction",
"posts": "https://schema.org/WriteAction",
"reshares": "https://schema.org/ShareAction",
}
PLACEHOLDER_IMAGE = re.compile(r"default|placeholder|avatar-blank|blank-avatar|gravatar\.com/avatar/[^?]*\?.*d=", re.I)
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_profile_entity_type(source):
return "Organization" if source.get("isOrganization") else "Person"
def build_counters(stats, allowed):
return [
{"@type": "InteractionCounter", "interactionType": allowed[key], "userInteractionCount": count}
for key, count in (stats or {}).items()
if key in allowed and isinstance(count, int) and not isinstance(count, bool) and count >= 0
]
def build_profile_page_schema(source):
if not passes_page_gates(source):
return None
# The page must be about one person or organization affiliated with the site.
if not source.get("isAffiliatedWithSite"):
return None
# Prefer the real name; a handle in name is fine when it is the only identifier.
name = (source.get("realName") or source.get("handle") or "").strip()
if not name:
return None
alternate_name = source["handle"] if source.get("realName") and source.get("handle") else None
image_url = source.get("imageUrl")
image = image_url if is_absolute_url(image_url) and not PLACEHOLDER_IMAGE.search(image_url) else None
date_created = to_iso_date_time(source.get("dateCreated"))
date_modified = to_iso_date_time(source.get("dateModified"))
if date_created and date_modified and to_timestamp(date_modified) < to_timestamp(date_created):
date_modified = None
return compact({
"@context": "https://schema.org",
"@type": "ProfilePage",
"dateCreated": date_created,
"dateModified": date_modified,
"mainEntity": {
"@type": choose_profile_entity_type(source),
"name": name,
"alternateName": alternate_name,
"identifier": str(source["internalId"]) if source.get("internalId") is not None else None,
"description": source.get("byline"),
"image": image,
"sameAs": [u for u in source.get("externalProfiles") or [] if is_absolute_url(u)],
"interactionStatistic": build_counters(source.get("profileStats"), PROFILE_STATS),
"agentInteractionStatistic": build_counters(source.get("activityStats"), AGENT_STATS),
},
})
# Usage: json_ld = build_profile_page_schema(source) # same source dict as the JS example; None means emit nothing
{
"@context": "https://schema.org",
"@type": "ProfilePage",
"dateCreated": "2024-03-11T09:12:00Z",
"dateModified": "2026-09-30T18:45:00Z",
"mainEntity": {
"@type": "Person",
"name": "Priya Raman",
"alternateName": "priya_r",
"identifier": "48213",
"description": "Technical SEO lead, writes about rendering and crawl budget.",
"image": "https://forum.example.com/uploads/avatars/priya_r-400.jpg",
"sameAs": [
"https://www.linkedin.com/in/example-priya",
"https://priya.example.net"
],
"interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/FollowAction",
"userInteractionCount": 1820
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/LikeAction",
"userInteractionCount": 5310
}
],
"agentInteractionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/FollowAction",
"userInteractionCount": 214
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/WriteAction",
"userInteractionCount": 642
}
]
}
}
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
- Handles profiles with a real name, a handle only, or both, without separate templates.
- Omits statistics, images, and external profiles the account does not have instead of emitting zeros or placeholders.
- Keeps the two statistic lists on their own allowed action types, a mistake static templates make easily.
- Keeps identifier stable through handle changes by sourcing it from the internal user ID.
Property Reference
What Google's Profile page 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 | |
|---|---|---|---|
| mainEntity | Required | Person or Organization | The Person or Organization that this profile page is about. |
| mainEntity.name | Required | Text | The primary way the person or organization is identified. |
| mainEntity.agentInteractionStatistic | Recommended | InteractionCounter, can repeat | User statistics about the profile entity's own behavior, if applicable. One of: FollowAction, LikeAction, WriteAction, ShareAction. |
| mainEntity.alternateName | Recommended | Text | An alternate public identifier, for example a social media handle if the real name is used in name. |
| mainEntity.description | Recommended | Text | The user's byline or applicable credential, if applicable. |
| mainEntity.identifier | Recommended | Text | A unique identifier used within your site, if applicable. |
| mainEntity.image | Recommended | URL or ImageObject | The URL or ImageObject of a profile image. Don't use default, icon, or placeholder images. |
| mainEntity.interactionStatistic | Recommended | InteractionCounter, can repeat | User statistics applied to the profile entity on this platform, if applicable. One of: FollowAction, LikeAction, BefriendAction. |
| mainEntity.sameAs | Recommended | URL, can repeat | URLs of other external profiles or home pages for the profile, if applicable. |
| dateCreated | Recommended | DateTime | The date and time the profile was created, if applicable, in ISO 8601 format. |
| dateModified | Recommended | DateTime | The date and time information in the profile was modified, if applicable, in ISO 8601 format. |