Dillion Amartey

Notes

A daily quiz with link previews, on a free Cloudflare Worker

· 3 min read

Every morning I share one question from this site's Lab on LinkedIn and WhatsApp. The goal was a small daily habit that brings people back, without paying for servers or giving up a static site. This is how it works.

The constraints

  • The site is a static export: plain HTML, CSS and JavaScript files. That keeps it fast and lets it be hosted almost anywhere.
  • One question per day, the same for everyone, never repeated.
  • A shared link should unfurl into a card showing that day's question, not a generic site preview.
  • Show how other visitors did ("62% got this right") without accounts or cookies.
  • No running costs.

Questions are files, decided in advance

The question bank lives in a Python script. Running npm run qotd writes one small JSON file per day (public/lab/qotd/000.json, 001.json and so on) and records the order in qotd-order.json.

That order file matters most. Re-running the script never moves a day that's already scheduled; new questions go after the existing ones. So I can add questions at any time and nobody's "#14" ever changes. The browser works out today's number from the date and fetches one small file.

Previews need a server, but only a little one

Link previews come from og: meta tags, and LinkedIn's and WhatsApp's crawlers don't run JavaScript. A static page can't change its tags per day, so this is the one place the site needs code on a server.

A Cloudflare Worker sits in front of the static files, but only for a few paths:

"run_worker_first": ["/api/*", "/cv", "/cv/", "/qotd", "/qotd/*"]

Everything else is served straight from the static files and never runs Worker code. For /qotd the Worker:

  1. Redirects /qotd to today's number, for example /qotd/4, so the link I share is always "today".
  2. For /qotd/4, reads that day's JSON file from the static assets and returns a tiny HTML page with that question in its title, description and og:image.
  3. Sends people on to the game with a one-line script. Crawlers ignore the script and read the tags; people are redirected before they notice.

Future numbers redirect back to today, so nobody can peek at tomorrow's question.

A card per question, drawn at build time

Each preview image is a 1200 × 630 PNG showing the question and its four options, never the answer. A Node script draws them with sharp when the questions are generated. They're static files like everything else, so the Worker only checks that a card exists and points to it.

One thing caught me out: LinkedIn and WhatsApp cache previews per URL, sometimes for days. That's why each day has its own path instead of one /qotd page with changing tags. If a preview goes stale, adding ?v=2 to the link gives the crawler a new URL to fetch.

Answer stats in one SQL statement

When someone answers, the browser sends the question ID and whether they were right. The Worker stores counts in Cloudflare D1, its SQLite database, with a single upsert:

INSERT INTO bug_stats (id, attempts, solved) VALUES (?1, 1, ?2)
ON CONFLICT(id) DO UPDATE SET attempts = attempts + 1, solved = solved + ?2
RETURNING attempts, solved

RETURNING sends the new totals back in the same round trip, so there's no separate read. The percentage only appears after five attempts, because "100% got this right" from one visitor means nothing. The IDs are checked against a strict pattern, so the table can't fill up with junk.

What each visitor picked lives in their own browser's localStorage, so a refresh shows their answer and nothing about them leaves the device.

It fails gracefully

The same site is also deployed to a static-only host with no Worker. There the game still works: the question loads from its JSON file, and the stats call simply returns nothing and the percentage never shows. Share buttons on every copy point to the Worker's link, since that's the only one that can produce the preview.

The cost

Cloudflare's free plan includes 100,000 Worker requests a day and serves static files without limit. A personal site with a daily quiz uses a tiny fraction of that, so the running cost is zero.

What I'd change

The question bank is a single Python list, and adding questions means editing code. A small form that writes to the same file would make it easier to write questions on my phone. The bank also runs out in August 2027, so that's on the list.

Try today's question.