Skip to content

07 — public-site-archive (Design)

Status: Draft Date: 2026-05-23 Owner: frontend (Astro) Depends on: 04 auction-engine (D1 mirror auctions), 06 real-time-hub (HubDO lobby), 02 identity-onboarding (none — public) Binding refs: master §4.8 domain layout, D17 Astro, D19 i18n, D22 MVP scope; research/astro-cf-monorepo-patterns.md §1, §5, §9.


Purpose. Render the public face of nowyourlink.com — today’s Spotlight, the permanent /day/{n} archive, click-tracking redirect, OG images, SEO surface, i18n strings, like button — on a single @astrojs/cloudflare Worker, with most pages served from the cache edge.

In scope (this spec owns):

  • Astro app at apps/web deployed to nowyourlink.com (apex) and nowyourlink.com/app (same worker, different routes — dashboard route handled by 08).
  • Pages: /, /day/[n], /go/[auction_id], /archive, /about, /press, /legal/*.
  • OG image generation at cdn.nowyourlink.com/og/daily/{date}.png.
  • Click-tracking redirect /go/{auction_id} with anonymized click log + UTM rewrite.
  • SEO: robots.txt, sitemap.xml, JSON-LD Article + Organization, Open Graph, hreflang.
  • i18n via Paraglide (EN default, DE secondary).
  • Like button (cookie-id, per day) with KV store + D1 aggregate rollup.
  • The HTML/CSS slots where 09’s push opt-in widgets mount.

Out of scope (not owned):

  • Advertiser dashboard at nowyourlink.com/app/* (08 owns; same Astro worker, different route tree).
  • Push prompt, predict-the-price widget — 09 owns the islands; 07 only hosts the markup slots. (The Disqus thread loader and its cookie-consent banner were removed — see spec 09 §4.)
  • Auction bid endpoints, conversion pixel ingest (apps/api).
  • Authoring/moderating creatives, choosing the winner (03/04/05).
  • Cinematic WS island for live bidder count (09 owns the island; 07 hosts the mount point and the data attribute data-auction-id).

apps/web uses @astrojs/cloudflare with output: 'server' (research §1). Routes are SSR by default; static archive pages opt in with export const prerender = true.

apps/web/
astro.config.ts # adapter @astrojs/cloudflare, integrations: paraglide
wrangler.jsonc # vars, KV (NYL_SESSION, NYL_LIKES), service bindings (API, WS), R2 (OG_CACHE), D1 (NYL_D1)
src/
env.d.ts # references worker-configuration.d.ts (wrangler types)
layouts/
Base.astro # <html lang> from locale; OG meta slot; consent banner
Day.astro # extends Base; structured-data Article slot
components/
SpotlightCard.astro # today's winner card
LiveCountdown.astro # WS island (mount only; src in 09)
LikeButton.astro # POSTs to /api/like
ShareButtons.astro
Sparkline.astro # SVG-only, server-rendered
BidGrid.astro # emoji grid (server-rendered)
DisqusSlot.astro # REMOVED (Disqus dropped — 09 §4); file dormant, not rendered
LangSwitch.astro
SeoHead.astro # JSON-LD + OG + hreflang
pages/
index.astro # /
day/[n].astro # /day/1, /day/2, ... (SSR + cache; see §4)
go/[auction_id].ts # endpoint, 302 redirect (see §6)
archive/index.astro # full list, paginated
about.astro
press.astro
legal/impressum.astro
legal/agb.astro
legal/datenschutz.astro
api/like.ts # POST endpoint (Astro endpoint, KV write)
api/og/[date].ts # SSR OG PNG (lazy generation; see §5)
paraglide/
messages/
en.json
de.json

SSR vs static.

RouteModeCache headerReason
/SSRs-maxage=60Today’s Spotlight changes daily; activity signals stream via WS island
/day/[n]SSR + edge caches-maxage=86400, stale-while-revalidate=604800Immutable once the day has elapsed
/go/[auction_id]SSR (endpoint)Cache-Control: no-storeClick log must run
/archiveSSRs-maxage=300List grows daily
/about, /press, /legal/*prerender = truestaticRarely changes
/api/likeSSRno-storeMutating endpoint
/api/og/[date].tsSSRs-maxage=86400, immutable after first buildSee §5

Astro.locals.runtime.env is fully typed via wrangler types (research §1). No DO bindings on apps/web — all WS traffic goes via service binding to apps/ws (research anti-pitfall §10).


pages/index.astro renders, in order:

  1. Hero / Spotlight card — SpotlightCard.astro reads from D1 mirror table daily_spotlight(date PRIMARY KEY, ad_id, title, description, image_url, target_url, advertiser_company, winning_amount_cents, bidder_count, winning_at) (populated by 05 on saga.activated; winning_at mirrors spec-04 closes_at_actual as ISO-8601 UTC and is the timestamp the archive header in §4 displays). Falls back to the house-ad row (ad_id LIKE 'house:%') when no winner. The house: prefix is the spotlight-layer marker pickHouseAd applies at selection (10 §9 / #340 — ads.id itself stays a normal UUID); this ad_id LIKE 'house:%' check is the authoritative consumer and is unchanged. Never calls AuctionDO directly.
  2. Live countdown to tomorrow’s auction — LiveCountdown.astro is a mount point (<div data-island="live-countdown" data-auction-id="${tomorrow}">). The actual interactive island ships from 09; it connects to wss://ws.nowyourlink.com/auction/${tomorrow} and renders bidder count + heat index (the privacy-safe signals defined in 06 §5). 07 only ships the SSR placeholder showing the planned closes_at from the D1 mirror.
  3. Share buttons — ShareButtons.astro: mailto:, Twitter/X intent, LinkedIn, copy-link. No JS frameworks; tiny inline handlers.
  4. Like button — LikeButton.astro (§9).
  5. Disqus slot — REMOVED (2026-06-13). The comment embed was dropped from the product (spec 09 §4); the homepage renders no comment thread. Item number retained so prior 07 §3.5 references resolve.
  6. Footer — language switch, legal links, build hash.

The homepage SSR call hits D1 only, never AuctionDO. WS activity signals flow via the island (06), not through 07.


Permanent record of the Spotlight for the Nth day since launch. URL maps to a calendar date via the D1 mirror (day_index integer column on daily_spotlight; n=1 is the first activated ad day).

src/pages/day/[n].astro SSR + edge-cache (s-maxage=86400, stale-while-revalidate=604800):

  • Header — date, day number, winning amount, bidder count, view count (D1 aggregate).
  • Winning ad block — rendered exactly as it appeared on the apex that day: image, title, description, target_url. The clickable link is removed after 30 days per PDF — link rendered only if now - ad_day < 30d; after that, the target URL is displayed as plain text and not anchored.
  • Mini-sparkline — Sparkline.astro SVG from bids_by_hour aggregate row, server-rendered, no JS.
  • Bidder-count line + emoji bid-grid — BidGrid.astro renders one emoji per accepted bid (capped at 200); colours by relative amount band; tooltip shows hour-of-day. Server-rendered.
  • Archived Disqus thread — REMOVED (2026-06-13) — Disqus dropped (spec 09 §4); the archive page renders no comment thread.
  • Prev/next nav — /day/${n-1} and /day/${n+1} if within bounds.
  • Inter-links — “Record days” (top 5 by amount). (The “Most discussed” link, which ranked by Disqus comment count polled into day_stats, is removed alongside Disqus.)

JSON-LD Article schema (§7), OG image points at https://cdn.nowyourlink.com/og/daily/${date}.png (§5). <link rel="canonical" href="https://nowyourlink.com/day/${n}">.


Renderer choice. @resvg/resvg-wasm in a Worker route at apps/web/src/pages/api/og/[date].ts (research §1 confirms Astro endpoints can serve binary; deferred items in master §9 lists this as 07’s call). Resvg-wasm is the right pick: pure-WASM SVG→PNG, no Node deps, ~700 KB WASM cached at module scope, ~50–80 ms render for a 1200×630 PNG. We do not use Cloudflare Images at MVP — the cost (per-image transformation fees) is not justified for one PNG/day cached aggressively.

Pipeline.

  1. Request to https://cdn.nowyourlink.com/og/daily/${date}.png.
  2. Edge cache check (caches.default.match(req)); hit → return.
  3. R2 cache check — OG_CACHE.get(\og/daily/${date}.png`)`; hit → cache.put + return.
  4. Miss → fetch daily_spotlight row from D1 + bids_by_hour for sparkline; build SVG via tagged-template literal (no full templating engine — keeps cold start under 5 ms); resvg.render(svg).asPng().
  5. Write to R2 (OG_CACHE.put(...)), then cache.put(req, response.clone()), then return.
  6. Daily cron at 00:10 UTC pre-warms today’s and tomorrow’s OG PNG (apex cron is in apps/cron, not here; this spec defines only the route).

SVG layout (1200×630):

  • Gradient header (brand colours).
  • Brand mark top-left.
  • “Day {n} · {date}” subtitle.
  • Winner amount ({eur}€), large, centred.
  • Bidder count + sparkline (40px high) below the amount.
  • Footer: nowyourlink.com/day/{n}.

Cache invalidation. R2 object key is date-scoped, so a re-render after a saga retroactively updates the row (rare) requires explicit purge: apps/api exposes an admin RPC purgeOG(date) that deletes the R2 key and cache.delete(req). Sitemap regen (§7) also nudges the URL once.

Edge cache header. Cache-Control: public, max-age=31536000, immutable, s-maxage=86400; once a day has elapsed and saga is settled, the PNG is effectively immutable.


src/pages/go/[auction_id].ts is a Worker endpoint (not a page):

export const GET: APIRoute = async ({ params, request, locals, clientAddress }) => {
const { env } = locals.runtime;
const auction_id = params.auction_id!; // "YYYY-MM-DD"
// 1. Validate format + look up the spotlight row from D1 mirror
const row = await env.NYL_D1.prepare(
"SELECT target_url, status FROM daily_spotlight WHERE date = ?"
).bind(auction_id).first();
if (!row || row.status !== "settled") return new Response("Not Found", { status: 404 });
// 2. Compute anonymised visitor_id (research §9 pattern)
const salt = await env.NYL_DAILY_SALT.get("today"); // KV, rotated daily by cron
const ip = clientAddress.split(":").length > 2
? truncateIpv6(clientAddress, 48)
: truncateIpv4(clientAddress, 24);
const ua = request.headers.get("user-agent") ?? "";
const visitor_id = await sha256(`${salt}:${ip}:${ua}`);
// 3. Fire-and-forget click log to api (Queues consumer writes to D1)
locals.runtime.ctx.waitUntil(env.API.fetch("https://api.internal/_click", {
method: "POST",
body: JSON.stringify({
auction_id, visitor_id_hash: visitor_id,
referrer: request.headers.get("referer"),
device_hint: parseDeviceHint(ua), // "mobile" | "tablet" | "desktop" | "bot"
ts: new Date().toISOString(),
}),
}));
// 4. UTM redirect
const url = new URL(row.target_url as string);
url.searchParams.set("utm_source", "nowyourlink");
url.searchParams.set("utm_medium", "spotlight");
url.searchParams.set("utm_campaign", auction_id);
return Response.redirect(url.toString(), 302);
};

Idempotency / dedup. A click is “the same click” if (visitor_id, auction_id, hour_bucket) matches an existing row. The api consumer enforces this; 07 always logs; the consumer dedupes on insert. Daily salt rotation guarantees cross-day un-linkability (research §9).

Bot filter. cf.botManagement.score < 30 rows are tagged is_bot=true and excluded from advertiser-visible counts but kept in raw logs. Sustained abuse → Turnstile (§11). The redirect itself is never gated by Turnstile (it would break the user experience).

Service binding. env.API is a service binding to apps/api; never a public fetch().


  • robots.txt — static, served from public/robots.txt. Allow everything by default; Disallow: /api/, Disallow: /go/, Disallow: /app/. Sitemap URL declared. Modelled after the world-forest “discovery” robots block (allow indexing, disallow ingest endpoints).
  • sitemap.xml — SSR endpoint at /sitemap.xml enumerating /, /about, /press, /legal/*, and all /day/${n} entries (one row per daily_spotlight where status='settled') with <lastmod> = the ad-day date. Pagination via <sitemapindex> only if entries exceed 50 000 (years away).
  • JSON-LD — every /day/[n] emits Article (headline, datePublished = ad-day midnight UTC, image = OG URL, author = { Organization: nowyourlink }). Homepage emits Organization.
  • Open Graph — og:image points at cdn.nowyourlink.com/og/daily/{date}.png for / (today) and /day/[n]. og:title, og:description localised per Accept-Language.
  • hreflang — <link rel="alternate" hreflang="en" href="https://nowyourlink.com/en/..."/>, de, and x-default → en. Locale prefix is optional on EN (default); explicit on DE (/de/day/...).

Paraglide (D19, master). Default en. Locale resolution:

  1. URL prefix (/de/...) → highest priority.
  2. nyl_locale cookie.
  3. Accept-Language parsing (first match against ["en","de"]).
  4. Fallback en.

src/paraglide/messages/{en,de}.json keyed by dotted namespace (home.hero.title, day.bidGrid.tooltip). The LangSwitch.astro component writes the cookie and 302s to the prefixed URL. SSR sets <html lang="..."> from the resolved locale. Date and currency formatting via Intl.* with the resolved locale.


Anonymous per-visitor, per-day. No auth.

  • Visitor id. First visit sets cookie nyl_visitor to a random UUID (SameSite=Lax, 1y).
  • POST /api/like — body { date, action: "like"|"unlike" }. Endpoint:
    1. Reads nyl_visitor cookie; if missing, sets one and proceeds.
    2. Writes/deletes NYL_LIKES.put(\like:${date}:${visitor_id}`, “1”, { expirationTtl: 31536000 })` (1y).
    3. Increments/decrements day_stats.like_count in D1 via a single statement (INSERT ... ON CONFLICT DO UPDATE).
    4. Returns { liked: boolean, count: number }.
  • SSR initial state. Page render does a single NYL_LIKES.get(\like:${date}:${visitor_id}`)` to render the correct toggle.
  • Spam resistance. If like_count velocity exceeds a threshold (>100/min for a given day), the endpoint requires a Turnstile token for the next 10 min (Turnstile site/secret keys are already in master §4.7).

Likes are a vanity metric; we intentionally do not rate-limit per IP harder than Turnstile — false positives are worse than mild inflation.


  • Adapter mode — single Worker per research §1 (output: 'server', per-route prerender).
  • D1-mirror reads only. Every public page reads from daily_spotlight, day_stats, bids_by_hour — never AuctionDO. The DO is too expensive to wake per page render (master §10.2 — DO duration is a top-3 cost driver).
  • Edge cache — see §2 table. /day/[n] is the workhorse and is cached for a day with SWR for a week.
  • R2 — backs OG PNG cache (out of scope here).
  • Cloudflare Images — not used at MVP. Direct R2 + Cache API is cheaper for our one-PNG-per-day pattern; we revisit if user-uploaded variants are needed.
  • Worker subrequest budget — each /day/[n] render does 1 D1 read on a no-row miss, else 3 (daily_spotlight + bids + bids_by_hour, the latter two issued concurrently — #362), plus 1 KV read; well under the 50-subrequest cap (research §10).
  • Sentry — @sentry/cloudflare instrumented on apps/web per master §4.6; SSR-error route fingerprinted by route_pattern not pathname to avoid cardinality explosion.

#CaseHandling
E1Auction has no winner (saga exhausted, house-ad fallback)daily_spotlight.ad_id is a house-ad row; SpotlightCard renders normally. /go/{date} 404s for house ads (no UTM rewrite).
E2Winning ad revoked between activation and nowD1 mirror has ad_status='revoked'. Render a “This ad was retracted” stub on /day/[n] (same layout, content blanked). 03 admin tool sets the flag.
E3/day/0 (pre-launch)404 with friendly “We haven’t launched yet” page. day_index starts at 1.
E4Bot traffic on /goBot-scored requests are logged + tagged but still redirected (users behind weird CDNs look like bots; we don’t want false 403s). Advertiser counts exclude bots.
E5DST edge for /day/n mappingWe do not use local time. day_index is a monotonic integer over UTC dates; the calendar mapping never depends on DST. Display formatting uses the visitor’s locale separately.
E6KV miss on nyl_visitor like check (eventual consistency)Treat as “not liked”; the next click flips state correctly. KV is eventually consistent (research §1) — acceptable for a vanity counter.
E7OG renderer cold-start spikeThe cron at 00:10 UTC pre-warms today and tomorrow. Lazy renders cap at one concurrent build per date via a KV mutex (og:lock:{date} 10s TTL).
E8/go/{auction_id} with malformed auction_id404; never proxy unknown URLs.
E9Disqus consent declinedRetired (2026-06-13) — Disqus removed (spec 09 §4).
E10Locale cookie set to unsupported valueFall back to en and rewrite the cookie.

  1. pnpm --filter @nowyourlink/web dev serves /, /day/1, /go/2026-06-04, /api/og/2026-06-04.png in wrangler dev.
  2. /day/[n] renders identical HTML for the same n across reloads (cache stable).
  3. /go/{date} 302s to the advertiser URL with UTM params; click log lands in D1 within 5 s.
  4. OG PNG returns 200 < 200 ms after first generation; cron pre-warm yields cache HIT within the first render.
  5. Robots.txt and sitemap.xml validate; sitemap includes every settled day.
  6. Lighthouse SEO ≥ 95 on / and /day/[n].
  7. Switching language via LangSwitch persists across reloads; <html lang> and JSON-LD reflect the chosen locale.
  8. Liking + unliking flips correctly; refresh shows the persisted state via SSR.
  9. No page reads from AuctionDO (verified by grep + service-binding absence in wrangler.jsonc).
  10. Sentry captures a forced SSR error route-tagged /day/[n].

  • Package wiring (research §2): apps/web consumes @nowyourlink/shared for D1 schemas + zod types. No DO classes here.
  • Service bindings: API → apps/api for click log + like aggregation; WS_HUB not needed by 07 (the island in 09 connects directly to ws.nowyourlink.com).
  • R2 binding: OG_CACHE (private bucket), exposed publicly via a cdn.nowyourlink.com custom domain on the bucket (no extra Worker hop).
  • Wrangler: declare KV NYL_LIKES, NYL_DAILY_SALT; D1 NYL_D1; service API; R2 OG_CACHE. Do not add AUCTIONS DO namespace here (research anti-pitfall §10).
  • Paraglide: integrate via the official Astro adapter; messages compiled into src/paraglide/ at build.
  • Tests: SSR golden tests for /day/[n] markup + JSON-LD; snapshot tests for OG SVG (pre-resvg) to keep visual diffs reviewable; unit tests for truncateIpv4/truncateIpv6 + UTM rewrite.
  • No push JS in this package — only mount points. (Disqus and its consent banner were removed — spec 09 §4.)
  • Locale assets ship with the worker bundle; keep messages/*.json under 30 KB each.