Event
On this page. Name, times, status, venue, organizer, host and ticket.
One in-store workshop that moved to a new date: an Event with timezone-correct times, the store as venue, the Organization as organizer, the host and a free ticket.
Use on the page for one event people can attend in person, with the date, time and place visible on the page.
The venue is the store, with the same @id as its location page, and the organizer is the Organization from the homepage. When an event moves, the new date goes in startDate and the old one in previousStartDate, so Google can show it as rescheduled.
Each card opens the framework for that feature, with its own rules and property reference. Cards marked "Linked by @id" are defined on the homepage and only referenced here.
Every node has a stable @id. A node is written out once, where it belongs, and everywhere else it is referenced by that @id alone. This is the map for this page, generated from the output below.
| @id | Defined on | Type | Referenced by |
|---|---|---|---|
/events/rain-gear-care-workshop/#webpage | This page | WebPage | Top-level node |
/events/rain-gear-care-workshop/#breadcrumb | This page | BreadcrumbList | WebPage.breadcrumb |
/events/rain-gear-care-workshop/#event | This page | Event | WebPage.mainEntity |
/stores/portland/#location | This page | Place | Nested in Event.location |
/#organization | Homepage | OnlineStore | Event.organizer |
/#website | Homepage | WebSite | WebPage.isPartOf |
One function turns the page's data into the whole graph. It drops anything Google would reject instead of shipping it half built. The input below includes rows it is meant to drop, so you can see the logic work.
// Event page schema: one in-person workshop at one of the store's locations.
// Event with a start time in the venue's timezone, the store as the venue
// (same @id as its location page), the Organization from the homepage as
// organizer, the instructor as performer, a free ticket offer, and the
// page's WebPage and BreadcrumbList.
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(Z|[+-]\d{2}:\d{2})$/;
const COUNTRY = /^[A-Z]{2}$/;
const CURRENCY = /^[A-Z]{3}$/;
const clean = (v) => (typeof v === 'string' ? v.trim() : v);
const isUrl = (v) => typeof v === 'string' && /^https:\/\/[^\s]+$/.test(v);
const compact = (obj) => {
if (Array.isArray(obj)) {
const out = obj.map(compact).filter((v) => v !== undefined);
return out.length ? out : undefined;
}
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const c = compact(v);
if (c !== undefined) out[k] = c;
}
return Object.keys(out).length ? out : undefined;
}
return obj === null || obj === '' ? undefined : obj;
};
function breadcrumb(trail, id) {
const items = (trail || []).filter((c) => c.name && isUrl(c.url));
if (items.length < 2) return undefined;
return {
'@type': 'BreadcrumbList',
'@id': id,
itemListElement: items.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(c.name), item: c.url })),
};
}
const STATUS = {
scheduled: 'https://schema.org/EventScheduled',
rescheduled: 'https://schema.org/EventRescheduled',
postponed: 'https://schema.org/EventPostponed',
cancelled: 'https://schema.org/EventCancelled',
};
function buildEventPageSchema(source) {
const site = source.site || {};
const page = source.page || {};
const ev = source.event || {};
const venue = ev.venue || {};
if (!isUrl(site.url) || !isUrl(page.url) || !ev.name) return null;
// A start time needs its timezone, or the event shows at the wrong hour.
if (!ISO_DATE_TIME.test(ev.start || '')) return null;
if (!venue.street || !venue.city || !COUNTRY.test(venue.country || '')) return null;
const root = site.url.replace(/\/+$/, '');
const pageUrl = page.url;
const eventId = `${pageUrl}#event`;
const status = STATUS[ev.status] || STATUS.scheduled;
// An end time is only stated when it comes after the start.
const end = ISO_DATE_TIME.test(ev.end || '') && Date.parse(ev.end) > Date.parse(ev.start) ? ev.end : undefined;
// A rescheduled event keeps the new date in startDate and the old one in
// previousStartDate; a postponed one keeps its original date.
const previous = status === STATUS.rescheduled && ISO_DATE_TIME.test(ev.previousStart || '') ? ev.previousStart : undefined;
const offer = ev.ticket && CURRENCY.test(ev.ticket.currency || '') && Number.isFinite(ev.ticket.price) && ev.ticket.price >= 0
? compact({
'@type': 'Offer',
url: isUrl(ev.ticket.url) ? ev.ticket.url : pageUrl,
price: ev.ticket.price,
priceCurrency: ev.ticket.currency,
availability: ev.ticket.soldOut ? 'https://schema.org/SoldOut' : 'https://schema.org/InStock',
validFrom: ISO_DATE_TIME.test(ev.ticket.onSale || '') ? ev.ticket.onSale : undefined,
})
: undefined;
const event = compact({
'@type': 'Event',
'@id': eventId,
name: clean(ev.name),
description: clean(ev.description),
image: (ev.images || []).filter(isUrl),
startDate: ev.start,
endDate: end,
previousStartDate: previous,
eventStatus: status,
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
location: {
'@type': 'Place',
'@id': isUrl(venue.locationPageUrl) ? `${venue.locationPageUrl}#location` : undefined,
name: clean(venue.name),
address: {
'@type': 'PostalAddress',
streetAddress: clean(venue.street),
addressLocality: clean(venue.city),
addressRegion: clean(venue.region),
postalCode: clean(venue.postalCode),
addressCountry: venue.country,
},
},
organizer: { '@id': `${root}/#organization` },
performer: (ev.hosts || []).filter((h) => h.name).map((h) => ({ '@type': 'Person', name: clean(h.name) })),
offers: offer,
maximumAttendeeCapacity: Number.isInteger(ev.capacity) && ev.capacity > 0 ? ev.capacity : undefined,
});
const crumbs = breadcrumb(page.breadcrumbs, `${pageUrl}#breadcrumb`);
const webpage = compact({
'@type': 'WebPage',
'@id': `${pageUrl}#webpage`,
url: pageUrl,
name: clean(page.title) || clean(ev.name),
isPartOf: { '@id': `${root}/#website` },
breadcrumb: crumbs ? { '@id': crumbs['@id'] } : undefined,
mainEntity: { '@id': eventId },
});
return { '@context': 'https://schema.org', '@graph': [webpage, crumbs, event].filter(Boolean) };
}
{
"site": {
"url": "https://www.example.com"
},
"page": {
"url": "https://www.example.com/events/rain-gear-care-workshop/",
"title": "Rain Gear Care Workshop | Harbor & Pine Portland",
"breadcrumbs": [
{
"name": "Home",
"url": "https://www.example.com/"
},
{
"name": "Events",
"url": "https://www.example.com/events/"
},
{
"name": "Rain Gear Care Workshop",
"url": "https://www.example.com/events/rain-gear-care-workshop/"
}
]
},
"event": {
"name": "Rain Gear Care Workshop",
"description": "Bring a tired rain jacket and learn to wash, re-proof and patch it. Supplies provided.",
"images": [
"https://www.example.com/img/events/rain-gear-1x1.jpg",
"https://www.example.com/img/events/rain-gear-4x3.jpg",
"https://www.example.com/img/events/rain-gear-16x9.jpg"
],
"start": "2026-11-14T10:00:00-05:00",
"end": "2026-11-14T11:30:00-05:00",
"status": "rescheduled",
"previousStart": "2026-11-07T10:00:00-05:00",
"venue": {
"name": "Harbor & Pine Portland",
"locationPageUrl": "https://www.example.com/stores/portland/",
"street": "214 Commercial Street",
"city": "Portland",
"region": "ME",
"postalCode": "04101",
"country": "US"
},
"hosts": [
{
"name": "Maya Okafor"
}
],
"ticket": {
"price": 0,
"currency": "USD",
"url": "https://www.example.com/events/rain-gear-care-workshop/#register",
"onSale": "2026-10-01T09:00:00-04:00"
},
"capacity": 20
}
}
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": "https://www.example.com/events/rain-gear-care-workshop/#webpage",
"url": "https://www.example.com/events/rain-gear-care-workshop/",
"name": "Rain Gear Care Workshop | Harbor & Pine Portland",
"isPartOf": {
"@id": "https://www.example.com/#website"
},
"breadcrumb": {
"@id": "https://www.example.com/events/rain-gear-care-workshop/#breadcrumb"
},
"mainEntity": {
"@id": "https://www.example.com/events/rain-gear-care-workshop/#event"
}
},
{
"@type": "BreadcrumbList",
"@id": "https://www.example.com/events/rain-gear-care-workshop/#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://www.example.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Events",
"item": "https://www.example.com/events/"
},
{
"@type": "ListItem",
"position": 3,
"name": "Rain Gear Care Workshop",
"item": "https://www.example.com/events/rain-gear-care-workshop/"
}
]
},
{
"@type": "Event",
"@id": "https://www.example.com/events/rain-gear-care-workshop/#event",
"name": "Rain Gear Care Workshop",
"description": "Bring a tired rain jacket and learn to wash, re-proof and patch it. Supplies provided.",
"image": [
"https://www.example.com/img/events/rain-gear-1x1.jpg",
"https://www.example.com/img/events/rain-gear-4x3.jpg",
"https://www.example.com/img/events/rain-gear-16x9.jpg"
],
"startDate": "2026-11-14T10:00:00-05:00",
"endDate": "2026-11-14T11:30:00-05:00",
"previousStartDate": "2026-11-07T10:00:00-05:00",
"eventStatus": "https://schema.org/EventRescheduled",
"eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
"location": {
"@type": "Place",
"@id": "https://www.example.com/stores/portland/#location",
"name": "Harbor & Pine Portland",
"address": {
"@type": "PostalAddress",
"streetAddress": "214 Commercial Street",
"addressLocality": "Portland",
"addressRegion": "ME",
"postalCode": "04101",
"addressCountry": "US"
}
},
"organizer": {
"@id": "https://www.example.com/#organization"
},
"performer": [
{
"@type": "Person",
"name": "Maya Okafor"
}
],
"offers": {
"@type": "Offer",
"url": "https://www.example.com/events/rain-gear-care-workshop/#register",
"price": 0,
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"validFrom": "2026-10-01T09:00:00-04:00"
},
"maximumAttendeeCapacity": 20
}
]
}
The output above was run through the SchemaCDN validator, which checks every rule in Google's documentation for each feature. It was validated together with the homepage graph, so the @id links resolve. Result: no errors, and eligible for:
The anchor every other page type points at: Organization, site name and store-wide policies.
ProductGroup with a priced Offer per variant, member pricing and links to store-wide policies.
Article with a real Person author, the Organization as publisher, video and a paywalled section.
One store location with address, geo and hours, linked to its parent brand.
Recipe with timed steps, nutrition, ratings and a video whose key moments match the steps.
One open role with pay, closing date and work location, linked to the employer and the store.
A community thread that becomes Q&A or a discussion posting, depending on the thread.