An events listing looks like a simple content type until you notice that two things share the shape and behave nothing alike.
A dated concert exists once. It happens on 2026-09-10, and after that it is history. A weekly salsa night has no date at all. It happens every Thursday, indefinitely, until the venue stops running it.
Model both as one Event row with a date column and you will hit the same four problems in order. This is a writeup of the split, using a bilingual city guide for Santa Cruz de la Sierra as the worked example, because its URL structure makes the decision visible from outside.
The observable split
The guide routes the two kinds differently, and you can read the model straight off the slugs:
/events/arcangel-live-in-santa-cruz-2026-09-10-kn17z7/ dated, one-off
/events/mas-flow-festival-2026-09-05-by29yr/ dated, one-off
/events/salsa-bachata-autopia-weekly/ recurring, no date
/events/parlana-language-exchange-weekly/ recurring, no date
/events/stand-up-la-tuja-weekly/ recurring, no date
Dated events carry an ISO date plus a short random suffix. Recurring events carry a -weekly marker and no date at all.
That suffix on the dated slugs is doing real work. Venues run events with identical names repeatedly, so arcangel-live-in-santa-cruz is not unique across time, and arcangel-live-in-santa-cruz-2026-09-10 is still not unique if the same artist plays two shows on one day. The random suffix makes slug generation total instead of best effort, which matters because slug collisions in an ingest pipeline fail at write time, usually in a batch job at 3am.
The recurring slugs deliberately have no suffix, because there is exactly one weekly salsa night at that venue and its URL should be stable forever.
Problem one: the archive policy is opposite
A dated event should fall out of the listing the day after it happens, and ideally keep returning 200 rather than 404 so that inbound links and any indexed page survive.
A recurring event should never fall out. It has no expiry.
If both live in one table keyed on date, your "hide past events" filter is WHERE date >= today, and every recurring event with a null date silently vanishes from the site. The fix people reach for first is a sentinel date far in the future, which then corrupts every sort and every "next event" query downstream.
Two types, or one type with an explicit discriminator, and the filter branches on the discriminator rather than on the date.
Problem two: structured data disagrees
schema.org/Event requires startDate. There is no valid way to emit an Event for "every Thursday" using a single node, because the vocabulary models an occurrence, not a rule.
EventSeries plus eventSchedule with a Schedule node is the correct representation for the recurring case:
{
"@type": "EventSeries",
"name": "Salsa and bachata at Autopía",
"eventSchedule": {
"@type": "Schedule",
"byDay": "Thursday",
"startTime": "20:00",
"repeatFrequency": "P1W"
}
}
If you emit a plain Event with a fabricated startDate for a recurring night, you are asserting a specific occurrence that may not happen, and the assertion is machine readable. That is a correctness problem before it is an SEO problem.
Problem three: i18n doubles the surface, but not evenly
The guide runs a full Spanish mirror with localized path segments, not just a locale prefix:
/events/... <-> /es/eventos/...
/places/... <-> /es/lugares/...
/things-to-do/ <-> /es/que-hacer-en-santa-cruz/
Localized segments are better for users and for regional search than /es/events/, and they cost you a routing table. The naive prefix + same path approach cannot express things-to-do becoming que-hacer-en-santa-cruz, so the mapping has to be data, not string concatenation.
The trap specific to events is that the slug body should usually not be translated even when the segment is. The Spanish mirror keeps arcangel-live-in-santa-cruz-2026-09-10 unchanged under /es/eventos/. Translating the slug body means every event has two unrelated identifiers, every hreflang pair has to be looked up rather than derived, and a mistranslation permanently forks the URL.
Segment translated, identifier stable, hreflang derivable. That is the combination that stays maintainable.
Problem four: ingest is announcement-shaped, not database-shaped
The source data here arrives as public announcements from venues, largely through social posts in Spanish. That input has no schema, no stable identifier, and no update semantics. A venue posts a night, then posts a correction to the time, then posts an image with the real time in the image.
Two consequences worth designing for.
Deduplication cannot key on the title, because the same event gets announced repeatedly with different phrasing. Venue plus date plus rough time is a workable composite key, with the title as a similarity check rather than an identity check.
And every record needs a verified_at timestamp separate from updated_at. updated_at tells you when your row changed. verified_at tells you when a human last confirmed it against the source, which is the only field that lets you show a staleness warning or decide what to recheck before publishing.
The shape that works
One discriminated type, not two tables:
| Field | Dated | Recurring |
|---|---|---|
kind |
occurrence |
series |
starts_at |
required | null |
schedule_rule |
null | required |
expires |
day after starts_at
|
never |
| structured data | Event |
EventSeries + Schedule
|
| slug | name + date + suffix | name + marker |
A discriminated union rather than separate tables, because listing pages need both interleaved in one query, and the union keeps that a filter rather than a join.
The general lesson is that "recurring" is not a property of an event. It is a different kind of thing that happens to render in the same list, and the cheapest moment to separate them is before the first row is written.
The guide used as the example throughout is Bolivamos, a current events guide for Santa Cruz de la Sierra, Bolivia, at bolivamos.com. Disclosure: I work on it, which is why I can describe the ingest side rather than only the URLs.
Top comments (0)