Time-Limited Pages
A time-limited page is a page about something with a start date and an end date. Example: one round of the McDonald's Best Crew Challenge.
The index is Google's database of pages it can show in search results. A page is indexed when it is in that database. The meta tag
robots: noindextells Google to remove the page from it.A soft 404 is Google's name for a page that returns HTTP
200but looks like an error page. Google removes these from the index on its own.The canonical URL is the one address we tell search engines to treat as the page's real address.
An answer engine is an AI assistant that answers questions directly (ChatGPT, Perplexity, Google AI Overviews). AEO means writing pages so these engines can quote us correctly.
Why this doc exists
The McDonald's Best Crew Challenge is a recurring incentive. A worker completes McDonald's shifts inside a date window and earns FairPrice vouchers. The page at /rewards/incentives/mcd-best-crew states the rewards, the dates, and the terms.
Marketing starts a round when McDonald's job fulfilment is low. We cannot know the dates in advance. A round lasts one to two weeks. Between 2025 and 2026 we ran four rounds in nine months.
Every round raised the same three questions:
- Do we create a new URL for the new round?
- Do we keep the page indexed after a round ends?
- Can we rewrite the page content without hurting SEO?
This doc records the answers and the reasoning, so the next round is a routine data change.
The mental model: identity and state
Search engines score URLs, not page content alone. Google keeps a history for each URL:
- how often it crawls the URL
- which pages link to it
- which search queries it answered
- how searchers behaved after clicking it
This history grows for months and years. It is the reason an old page outranks a new copy of itself. So we split every time-limited page into two parts:
| Part | What it holds | Does it change? |
|---|---|---|
| Identity | the URL, the <h1>, the page title, what the program is | never |
| State | dates, rewards, terms, banner, "Active" or "Ended" badge | every round |
The rule in one sentence: time belongs to the page's state, never to the page's identity.
Question 1 — one URL, or a new URL each round?
One URL. A new round updates the content of the existing page.
- Google's score for a URL builds over months. A new URL starts at zero. A two-week round is too short to build anything.
- Push links never die. About 90% of traffic to these pages comes from push (Eber, Telegram, CleverTap). One stable URL means old messages, QR codes, and saved links still work.
- Answer engines learn one address for "the Jod McDonald's incentive". A new URL each round splits that knowledge into pieces.
This is also Google's own advice for repeating sale events. See Google's Black Friday best practices — use a recurring URL, not a new URL for each occurrence.
| The page describes... | URL choice |
|---|---|
| a new round of the same program | same URL. Update the content. |
| a different program with a different name | new URL. |
| a past round we must keep readable | dated archive URL, linked from the main page. Rarely needed. |
We learned this the hard way. Each McD round used to get its own dated URL under /campaigns. Issue #1220 — consolidate the dated McD pages into one page removed them, and PR #1285 — 301 redirects for the old dated URLs pointed the old addresses at the one page.
If Marketing needs a separate link per round for tracking, add UTM parameters to the same URL. Do not create a new path.
Question 2 — keep the page indexed after a round ends?
Yes. For a recurring program, the page stays indexed in every state.
First the general rule. An indexed page is a promise: anyone who lands here from search finds something useful. Google gives us four tools for pages whose offer has ended. Pick by how alive the thing is:
| The thing on the page is... | What to do |
|---|---|
| a program that will run again | keep 200. Update the content per state. Stay indexed. |
| finished forever, and another page replaces it | 301 redirect to that page, or to the hub. |
| finished forever, and nothing replaces it | return 404 or 410. |
| only for direct links (push, QR), never meant for search | keep 200, add noindex. |
The McD page fits the first row. Three facts together force this choice:
- Marketing starts a round at short notice. There is no lead time to prepare Google.
- Google crawls this page about once every few weeks. (Search Console showed a last crawl of 29 July 2026.)
- A round lasts one to two weeks.
Put these together. If the page leaves the index between rounds, Google cannot put it back in time. The round ends before Google returns. A page that never leaves the index is ready on day one of any surprise round.
History. Issue #1308 — mark the McD incentive as ended and de-index the detail page added noIndex when a round ends. At that time we treated the round as a one-time event. Four rounds later we know the program repeats, so the first row of the table applies. When we next touch the page, remove the noIndex option and keep the ended state indexed.
If Marketing ever insists that an ended offer must not appear in search, noindex is the honest tool. But name the cost out loud: the next round starts with zero search presence, and Google may not return before it ends.
Question 3 — can we rewrite the page without hurting SEO?
Yes. There is no punishment for changing the content of a URL. Fresh content on an old URL is a positive signal. Four real risks exist, and each has a plain cure:
| Risk | What it looks like | How we avoid it |
|---|---|---|
| soft 404 | the ended page shows only "This incentive has ended." Google treats it as an error page and drops it. | keep the full round content in the ended state: rewards, dates, terms. The "Ended" badge is one small part of a full page. |
| the page changes topic | the McD page becomes a page about a FairPrice program. The URL's history no longer matches. The score resets. | the page stays about the same program forever. Only round details change. |
| structured data that lies | Event or JobPosting markup still live after the end date. Google can issue a manual penalty for this. | incentive pages carry only BreadcrumbList. Keep it in every state. Never add Event or JobPosting here. |
| old and new round data mixed | the terms say March, the banner says August. Google and answer engines quote the wrong dates. | replace the whole round at once. mcdIncentives in incentives-data.js already works this way: one object per round. |
The soft 404 check is one question. Cover the "Ended" badge with your thumb. Is this still a full page about the program? If yes, the page is safe to keep indexed. If the page only says that something is missing, it is not.
The three states of the page
The page renders one of three states. All three return 200 on the same URL. All three keep robots: index, follow.
| State | When | The worker sees |
|---|---|---|
| upcoming | today is before startDate | the new round's dates and rewards, with "Starts 20 August 2026" |
| active | today is inside the date window | the full round, an "Active" badge, and a button to the eligible shifts |
| ended | today is after endDate | the full last round, an "Ended" badge, and a button to /rewards/incentives |
Three things stay constant across all states:
- the canonical URL
- the
<h1>and the page title - full dates written in the page text: "20 – 30 August 2026", never "this month"
The last point is for answer engines. They quote page text. A full date stays true even when the quote is read months later.
Compute the state at request time
The state must come from the time of the request, never from the time of the deploy.
Today incentives-data.js breaks this rule. It reads today's date at the top level of the module. On the server that code runs once, when the server process starts. The page state then freezes until the next deploy. Two problems follow:
- a round can start or end, and the live page does not change
- the date uses UTC (
toISOString()). Singapore is 8 hours ahead. The state would change at 8am Singapore time, not at midnight.
The fix: pick the current round inside the route loader, on every request. Use the entity's timezone, following the datetime convention — the entity's timezone, never the browser's and never a hardcoded offset.
This matters most for a program with no advance notice. Once the state is request-time, we can deploy the round config the day Marketing decides. The page then changes state by itself on the right dates. No deploy has to happen at midnight.
A worked example
One real round, start to finish:
- On 14 August 2026, McDonald's fulfilment is low. Marketing asks for a round from 20 to 30 August.
- The same day, an engineer adds one object to the top of
mcdIncentivesinincentives-data.js. It carriesstartDate: '2026-08-20',endDate: '2026-08-30', the banner paths, the reward tiers, and the terms. - We deploy the same day. The page shows the upcoming state. The card on
/rewards/incentivesbecomes active again on its own. - The engineer opens Search Console and requests indexing for the URL. Google then re-reads the page within days instead of weeks.
- On 20 August, at midnight Singapore time, the page switches to the active state. No deploy.
- Push messages go out. They use the same URL as every past round.
- On 31 August the page switches to the ended state. No deploy. The page stays indexed, with full content and an "Ended" badge.
Steps 5 and 7 only work after the request-time fix from the previous section. Until then, a deploy is what moves the page between states.
What we never do
- Never create a dated URL for a new round. That was the old
/campaignspattern, and every round added one more dead page. - Never delete the page or return
404when a round ends. The program repeats. - Never strip the ended state down to one "this has ended" line. That invites a soft 404.
- Never add
EventorJobPostingmarkup to incentive pages. The markup would lie the moment the round ends.