Most content on a site is roughly as true next year as it is today. Event content is not. A page about a festival, a conference, a deadline, or a seasonal window is correct for a narrow period and quietly wrong for the rest of its life, and nothing in the stack tells you when it crossed over.
The failure is familiar. Somebody searches for an annual event, lands on last year's page still ranking, and reads dates that have not been true for eleven months. The page is not broken. No monitor fires. It just lies.
Expiry is a property of the content, so it belongs in the content model.
Model the window, not the publish date
The instinct is to add updatedAt and show it. That records when someone touched the file, which is not the same as whether the facts still hold. A page edited last week can still carry last year's dates.
What you want is the window the content describes:
type EventWindow = {
startsAt: string // ISO date the event begins
endsAt: string // ISO date it ends
verifiedAt: string // when a human last confirmed these dates
recurrence?: 'annual' // whether a successor page is expected
}
Three states fall out of that, and each one wants different output:
| State | Condition | What the page should do |
|---|---|---|
| Upcoming | now < startsAt |
Lead with dates and planning info |
| Live | startsAt <= now <= endsAt |
Lead with today, drop the countdown framing |
| Past | now > endsAt |
Say so above the fold, link the successor |
The third state is the one everyone skips, and it is the one that decides whether the page is honest.
Render the state, do not just store it
A past-state page should not silently keep its future-tense copy. The minimum is a dated line at the top saying the event has ended, plus a link to the next occurrence if one exists. That costs a conditional:
{state === 'past' && (
<p>
This covers the {year} event, which ended {formatDate(endsAt)}.
{successorUrl && <a href={successorUrl}>See the {year + 1} guide</a>}
</p>
)}
Keeping the old page rather than redirecting is usually right. It holds its links and its history, and people do search for past editions. What is not right is letting it present itself as current.
Emit the dates as structured data too
If the dates live in the model, Event schema is nearly free, and it gives search engines an explicit signal about the window instead of making them infer it from prose.
Worth pairing with an honest dateModified. Bumping that on every build to look fresh is a habit worth dropping, because it decouples the signal from any real editorial act and there is no upside once the freshness is not believed.
Watch for the window, not for errors
Expired content produces no errors, so ordinary monitoring never sees it. The check is a scheduled job that queries for content whose endsAt has passed without a successor, and whose verifiedAt is older than some threshold. That query is the entire feature:
SELECT slug, ends_at, verified_at
FROM event_pages
WHERE ends_at < now()
AND successor_slug IS NULL
ORDER BY ends_at DESC;
Run it weekly and it becomes an editorial queue rather than an incident.
A live example
Annual events are the clearest case because the same page shape recurs forever. The Sturgis Motorcycle Rally runs a fixed August window each year, and its 2026 guide is a decent example of the upcoming state done properly: the dates are stated explicitly rather than as "this year", the verification date is visible, and the recurrence is obvious enough that a successor page is expected rather than an afterthought.
The general rule is that any page describing a moment should know when that moment ends. Store it, render it, and query for it.
Top comments (0)