Events site template

Seven pages with the markup already written and the links between them already wired. Replace every {{PLACEHOLDER}} with your own detail and you have a page set that validates.

Companion to the events visibility playbook, which explains why each piece is here. Prepared by SwarmSystem.

Before you start

How to fill this in

Order of operations

1. Decide your URL shape. Everything below assumes /events/<series>/ for the series and /events/<series>/<yyyy-mm>/ for each occurrence. If you change it, change it in every block, because the links are wired.

2. Fill the series page first. Its values repeat everywhere else.

3. Then one occurrence. Then copy that occurrence for each date.

4. Speakers and sponsors last, because they point back at occurrences that need to exist first.

5. Paste each JSON-LD block into the <head> of the matching page, inside a script tag of type application/ld+json.

Every placeholder looks like {{THIS}}. Search your files for two open braces before you publish. If any remain, you are not done. If your CMS already uses that syntax, switch to an HTML comment marker instead.

Dates are ISO 8601 with a timezone offset, for example 2026-10-15T18:00:00-05:00. Getting the offset wrong is the most common cause of a wrong time in a rich result.
1 of 7

Series page /events/{{SERIES_SLUG}}/

The evergreen home of a recurring gathering. Carries the schedule. Note the rule below: an event that carries a schedule must not also carry its own start and end dates.

{
  "@context": "https://schema.org",
  "@type": "EventSeries",
  "@id": "{{SITE_URL}}/events/{{SERIES_SLUG}}/#series",
  "name": "{{SERIES_NAME}}",
  "url": "{{SITE_URL}}/events/{{SERIES_SLUG}}/",
  "description": "{{SERIES_DESCRIPTION}}",
  "image": "{{SITE_URL}}{{SERIES_IMAGE_PATH}}",
  "organizer": {
    "@type": "Organization",
    "name": "{{ORGANIZER_NAME}}",
    "url": "{{SITE_URL}}"
  },
  "location": {
    "@type": "Place",
    "name": "{{VENUE_NAME}}",
    "address": {
      "@type": "PostalAddress",
      "streetAddress": "{{VENUE_STREET}}",
      "addressLocality": "{{VENUE_CITY}}",
      "addressRegion": "{{VENUE_REGION}}",
      "postalCode": "{{VENUE_POSTCODE}}",
      "addressCountry": "{{VENUE_COUNTRY}}"
    }
  },
  "eventSchedule": {
    "@type": "Schedule",
    "repeatFrequency": "{{REPEAT_FREQUENCY}}",
    "byDay": "{{BY_DAY}}",
    "byMonthWeek": {{BY_MONTH_WEEK}},
    "startTime": "{{START_TIME}}",
    "endTime": "{{END_TIME}}",
    "scheduleTimezone": "{{TIMEZONE}}",
    "exceptDate": "{{EXCEPT_DATE}}"
  }
}
Rules for this block

repeatFrequency takes a value like P1M for monthly. byDay takes a day such as Thursday. byMonthWeek takes a number, so 3 means the third of that weekday in the month. exceptDate is any date the series skips, and you can repeat that property for more than one.

Do not add startDate or endDate to this block. Schema.org is explicit that an event associated with a schedule defines its timing inside the schedule instead.

Honest caveat: Google does not document whether it reads eventSchedule. This block is semantically correct and useful to answer engines. Do not expect a rich result from it. The rich result lives on the occurrence page below.

Page content: what this gathering is, who it is for, who should not come, cadence, place, and the last few recaps as proof.
2 of 7

Occurrence, upcoming /events/{{SERIES_SLUG}}/{{OCCURRENCE_SLUG}}/

One dated instance. This is the only page where an event rich result can appear. Required properties are name, startDate, and a location with a real address. Everything after that is recommended and worth having.

{
  "@context": "https://schema.org",
  "@type": "Event",
  "@id": "{{SITE_URL}}/events/{{SERIES_SLUG}}/{{OCCURRENCE_SLUG}}/#event",
  "name": "{{OCCURRENCE_NAME}}",
  "url": "{{SITE_URL}}/events/{{SERIES_SLUG}}/{{OCCURRENCE_SLUG}}/",
  "startDate": "{{START_ISO}}",
  "endDate": "{{END_ISO}}",
  "eventStatus": "https://schema.org/EventScheduled",
  "eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
  "description": "{{OCCURRENCE_DESCRIPTION}}",
  "image": "{{SITE_URL}}{{OCCURRENCE_IMAGE_PATH}}",
  "superEvent": { "@id": "{{SITE_URL}}/events/{{SERIES_SLUG}}/#series" },
  "location": {
    "@type": "Place",
    "name": "{{VENUE_NAME}}",
    "address": {
      "@type": "PostalAddress",
      "streetAddress": "{{VENUE_STREET}}",
      "addressLocality": "{{VENUE_CITY}}",
      "addressRegion": "{{VENUE_REGION}}",
      "postalCode": "{{VENUE_POSTCODE}}",
      "addressCountry": "{{VENUE_COUNTRY}}"
    }
  },
  "organizer": {
    "@type": "Organization",
    "name": "{{ORGANIZER_NAME}}",
    "url": "{{SITE_URL}}"
  },
  "performer": [
    {
      "@type": "Person",
      "@id": "{{SITE_URL}}/speakers/{{SPEAKER_SLUG}}/#person",
      "name": "{{SPEAKER_NAME}}",
      "url": "{{SITE_URL}}/speakers/{{SPEAKER_SLUG}}/"
    }
  ],
  "sponsor": [
    {
      "@type": "Organization",
      "name": "{{SPONSOR_NAME}}",
      "url": "{{SPONSOR_URL}}"
    }
  ],
  "offers": {
    "@type": "Offer",
    "url": "{{REGISTRATION_URL}}",
    "price": "{{PRICE}}",
    "priceCurrency": "{{CURRENCY}}",
    "availability": "https://schema.org/InStock",
    "validFrom": "{{OFFER_VALID_FROM_ISO}}"
  }
}
Rules for this block

If the event is free, price is "0" and currency is still required. Keep the offer: it is how the listing shows that there is nothing to pay.

eventAttendanceMode has three values: OfflineEventAttendanceMode, OnlineEventAttendanceMode, and MixedEventAttendanceMode. Pick the true one.

Do not add aggregateRating or review to an event. Google does not support review or rating rich results for events, and inventing ratings for your own gathering is a policy risk for no gain.

The markup must match what a visitor can see on the page. If the price is in the markup it belongs on the page too.

Page content: what this session covers, who is speaking and the one thing they know, the practical detail in three seconds, and what to expect if it is your first time.
3 of 7

The same page, after same URL, type changed

The most important switch in the whole set. Once the event is over, replace the Event block above with this one. Same address. Nothing moves, nothing is hidden, every inbound link still works.

{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "@id": "{{SITE_URL}}/events/{{SERIES_SLUG}}/{{OCCURRENCE_SLUG}}/#recap",
  "headline": "{{RECAP_HEADLINE}}",
  "url": "{{SITE_URL}}/events/{{SERIES_SLUG}}/{{OCCURRENCE_SLUG}}/",
  "datePublished": "{{RECAP_PUBLISHED_ISO}}",
  "image": "{{SITE_URL}}{{RECAP_IMAGE_PATH}}",
  "author": { "@type": "Organization", "name": "{{ORGANIZER_NAME}}" },
  "publisher": { "@type": "Organization", "name": "{{ORGANIZER_NAME}}" },
  "about": {
    "@type": "Event",
    "name": "{{OCCURRENCE_NAME}}",
    "startDate": "{{START_ISO}}",
    "location": {
      "@type": "Place",
      "name": "{{VENUE_NAME}}",
      "address": {
        "@type": "PostalAddress",
        "addressLocality": "{{VENUE_CITY}}",
        "addressRegion": "{{VENUE_REGION}}",
        "addressCountry": "{{VENUE_COUNTRY}}"
      }
    }
  },
  "mentions": [
    { "@id": "{{SITE_URL}}/speakers/{{SPEAKER_SLUG}}/#person" }
  ]
}
Why the type changes

There is no event status meaning finished. Schema.org has five and none of them says the event is over. So a finished event should not stay described as an event.

Described as an article about the event, it cannot be offered as something to book, and everything that makes it valuable survives: the talks, the names, the photographs, the links.

Do not noindex this page. There is no Google instruction to do so. Your past events are the proof that the room is real.

Page content: what was said, the best line quoted, who was in the room, photographs of people, and what happened afterwards.
4 and 5 of 7

Speaker pages

The speakers hub at /speakers/ is a collection page. Each speaker gets a detail page, which is the one that earns its keep: a speaker's name plus their topic is a real search with very little competing for it.

{
  "@context": "https://schema.org",
  "@type": "Person",
  "@id": "{{SITE_URL}}/speakers/{{SPEAKER_SLUG}}/#person",
  "name": "{{SPEAKER_NAME}}",
  "url": "{{SITE_URL}}/speakers/{{SPEAKER_SLUG}}/",
  "image": "{{SITE_URL}}{{SPEAKER_IMAGE_PATH}}",
  "jobTitle": "{{SPEAKER_TITLE}}",
  "description": "{{SPEAKER_BIO}}",
  "sameAs": [
    "{{SPEAKER_LINKEDIN}}",
    "{{SPEAKER_WEBSITE}}"
  ],
  "performerIn": [
    { "@id": "{{SITE_URL}}/events/{{SERIES_SLUG}}/{{OCCURRENCE_SLUG}}/#event" }
  ]
}
Rules for this block

performer and performerIn are the correct and only pairing for speakers. There is no speakerIn property, whatever you may have been told.

sameAs should list profiles that genuinely belong to that person. It is how an engine links your page to the person it already knows about.

Honest caveat: Google does not document a rich result for a person page. Do this for entity clarity and for answer engines, and do not promise a client a SERP feature.

Then ask the speaker to link to this page. They want proof they spoke. It is the highest yield request in this entire template and it costs nothing.

The hub at /speakers/ uses CollectionPage and breadcrumbs. No event markup on it.
6 and 7 of 7

Calendar and sponsors

Both are collection pages. Neither carries event markup, and that is deliberate rather than an omission.

{
  "@context": "https://schema.org",
  "@type": "CollectionPage",
  "@id": "{{SITE_URL}}/calendar/#page",
  "name": "{{CALENDAR_TITLE}}",
  "url": "{{SITE_URL}}/calendar/",
  "description": "{{CALENDAR_DESCRIPTION}}",
  "isPartOf": { "@type": "WebSite", "name": "{{SITE_NAME}}", "url": "{{SITE_URL}}" },
  "breadcrumb": {
    "@type": "BreadcrumbList",
    "itemListElement": [
      { "@type": "ListItem", "position": 1, "name": "Home", "item": "{{SITE_URL}}/" },
      { "@type": "ListItem", "position": 2, "name": "{{CALENDAR_TITLE}}", "item": "{{SITE_URL}}/calendar/" }
    ]
  }
}
Why there is no event markup here

Google's documentation states that its event experience only supports pages focused on a single event, and directs you to mark up individual event pages rather than pages listing schedules or multiple events. A list of events in the markup of a calendar page produces nothing.

That frees the page to do the job it is actually good at. Once you stop treating it as a rich result surface, it becomes the densest set of internal links on the site, connecting every occurrence and every recap in one place a crawler can walk.

The sponsors page uses the same CollectionPage shape. Each sponsor is an Organization, and the link that ties a sponsor to an event lives in the sponsor property on the occurrence page, not here.

Linking discipline in full at internal-linking.swarmsystem.ai. Not repeated here.
Wiring

The links, already decided

Who links to whom

Series to the next occurrence, to the last three recaps, to the speakers hub, to sponsors.

Occurrence to the series, to each speaker on it, to each sponsor on it, to the previous recap.

Recap keeps every link the occurrence had, and adds links to whatever you published afterwards. Anchor text changes from "register for" to "what happened at". The address never changes.

Speaker detail to every occurrence they appeared at, and to those recaps.

Calendar to every occurrence and every recap. This is the hub.

Sponsors to the occurrences they backed, and out to the sponsor.

Check before publishing: search for two open braces across all files, confirm every date carries a timezone offset, and confirm nothing points at a page you have not built yet.

Fill the placeholders in the order given at the top and this wiring arrives by default.
SELF CHECK: 0 TOKENS · 0 EM DASHES · 0 CONSOLE ERRORS · PASSED
Book a call