The project was a small garden gathering page that had to do three things well: feel like a real invitation, collect RSVPs for a short time, and disappear cleanly afterward. The useful part was not the party. It was the pattern: one-off social software with a planned teardown.
This is the shape I would reuse: an isolated Astro route for the public surface, a tiny Cloudflare Worker and D1 database only while responses are needed, generated images treated as replaceable editorial assets, and a post-event conversion into a redacted thank-you note.
Why
Most event tools are optimized for scale, not intimacy. For a small gathering, the right artifact is closer to a printed card: specific, beautiful, lightly private, and temporary. The web version should have the same constraints. It should not become a permanent database of names, addresses, notes, and attendance.
So the first design decision was lifecycle, not layout. Before building the RSVP form, decide how it ends: remove the form, redact the page, delete the database, and keep only a non-sensitive memory of the thing.
What
-
An Astro page at
/events/my_eventwith scoped styles, not global overrides. -
A query-parameter gate for the casual-public-internet case, backed by
noindexheaders. -
A short-lived Cloudflare Worker for
POST /rsvpand a D1 table keyed by normalized email. - Generated garden images with a restrained editorial brief instead of decorative clutter.
- A post-event thank-you page with address, RSVP data, calendar files, and personal metadata removed.
How
Start with the page as the durable object. Keep the route specific, and let Astro scope the CSS. Import the site's root variables, then define the event's typography, spacing, and image treatments inside the route. The page can look bespoke without leaking its taste into the rest of the site.
| 1 | --- |
| 2 | import "../../styles/root.css"; |
| 3 | import heroImage from "../../assets/images/my-event-hero.jpg"; |
| 4 | --- |
| 5 | |
| 6 | <main class="event-page"> |
| 7 | <section class="hero"> |
| 8 | <img src={heroImage.src} alt="A quiet garden table after guests have left." /> |
| 9 | <div> |
| 10 | <p class="eyebrow">Saturday morning</p> |
| 11 | <h1>A small garden gathering</h1> |
| 12 | <p>Bring a book, find a chair, wander a little.</p> |
| 13 | </div> |
| 14 | </section> |
| 15 | </main> |
| 16 | |
| 17 | <style> |
| 18 | .event-page { |
| 19 | background: var(--color-background); |
| 20 | color: var(--color-text); |
| 21 | } |
| 22 | </style> |
For the RSVP window, create the smallest database that answers the operational question: who is coming, how many people, and is there anything useful to know?
| 1 | CREATE TABLE IF NOT EXISTS event_rsvps ( |
| 2 | id TEXT PRIMARY KEY, |
| 3 | created_at TEXT NOT NULL, |
| 4 | updated_at TEXT NOT NULL, |
| 5 | name TEXT NOT NULL, |
| 6 | email TEXT NOT NULL, |
| 7 | email_key TEXT NOT NULL UNIQUE, |
| 8 | guest_count INTEGER NOT NULL DEFAULT 1 CHECK (guest_count BETWEEN 1 AND 12), |
| 9 | arrival_window TEXT, |
| 10 | interests TEXT, |
| 11 | notes TEXT |
| 12 | ); |
| 13 | |
| 14 | CREATE INDEX IF NOT EXISTS idx_event_rsvps_updated_at |
| 15 | ON event_rsvps (updated_at DESC); |
The Worker should validate hard, store little, and expose the list only
behind an admin token. The public endpoint only needs to accept JSON and
upsert by email_key
so duplicate RSVPs become edits instead of duplicate rows.
| 1 | const json = (body, status = 200) => |
| 2 | new Response(JSON.stringify(body), { |
| 3 | status, |
| 4 | headers: { |
| 5 | "content-type": "application/json; charset=utf-8", |
| 6 | "access-control-allow-origin": "https://example.com", |
| 7 | }, |
| 8 | }); |
| 9 | |
| 10 | export default { |
| 11 | async fetch(request, env) { |
| 12 | const url = new URL(request.url); |
| 13 | |
| 14 | if (request.method === "POST" && url.pathname === "/rsvp") { |
| 15 | const input = await request.json(); |
| 16 | const email = String(input.email || "").trim(); |
| 17 | const emailKey = email.toLowerCase(); |
| 18 | |
| 19 | if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { |
| 20 | return json({ error: "Use a valid email address." }, 400); |
| 21 | } |
| 22 | |
| 23 | await env.DB.prepare(` |
| 24 | INSERT INTO event_rsvps ( |
| 25 | id, created_at, updated_at, name, email, email_key, guest_count, notes |
| 26 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
| 27 | ON CONFLICT(email_key) DO UPDATE SET |
| 28 | updated_at = excluded.updated_at, |
| 29 | name = excluded.name, |
| 30 | email = excluded.email, |
| 31 | guest_count = excluded.guest_count, |
| 32 | notes = excluded.notes |
| 33 | `).bind( |
| 34 | crypto.randomUUID(), |
| 35 | new Date().toISOString(), |
| 36 | new Date().toISOString(), |
| 37 | String(input.name || "").trim(), |
| 38 | email, |
| 39 | emailKey, |
| 40 | Number(input.guestCount || 1), |
| 41 | String(input.notes || "").trim() |
| 42 | ).run(); |
| 43 | |
| 44 | return json({ ok: true }); |
| 45 | } |
| 46 | |
| 47 | return json({ error: "Not found." }, 404); |
| 48 | }, |
| 49 | }; |
The gate is intentionally modest. A query string is not authentication. It is a way to avoid broadcasting the page to crawlers and casual passersby while still keeping the invitation frictionless for guests.
| 1 | export async function onRequest(context) { |
| 2 | const url = new URL(context.request.url); |
| 3 | const isEvent = url.pathname === "/events/my_event"; |
| 4 | const hasKey = url.searchParams.get("key") === context.env.EVENT_PAGE_KEY; |
| 5 | |
| 6 | if (isEvent && !hasKey) { |
| 7 | return new Response("<h1>Private note</h1>", { |
| 8 | status: 200, |
| 9 | headers: { |
| 10 | "content-type": "text/html; charset=utf-8", |
| 11 | "x-robots-tag": "noindex, nofollow", |
| 12 | "cache-control": "private, no-store", |
| 13 | }, |
| 14 | }); |
| 15 | } |
| 16 | |
| 17 | const response = await context.next(); |
| 18 | response.headers.set("x-robots-tag", "noindex, nofollow"); |
| 19 | return response; |
| 20 | } |
Make It Look Specific
The fastest way to make a one-off page feel cared for is to give the images a narrow brief. I used generated garden photography as editorial texture, then kept it small enough that the page still felt like an invitation instead of a campaign.
| 1 | Create restrained editorial garden photography for a small private invitation. |
| 2 | The garden should feel wild and slightly overgrown, with mismatched chairs, |
| 3 | morning light, dense plants, and no visible people, faces, house numbers, or |
| 4 | readable addresses. Kinfolk-like spacing, muted natural color, quiet composition. |
The same privacy rule applies to the images: no house numbers, no faces, no documents on tables, no names, no exact address clues. Treat generated assets as public, even when the page itself is gated.
Teardown
After the event, the page became a thank-you note. The RSVP form, endpoint references, calendar file, address, and attendee-specific details were removed from the static output. Then the temporary Cloudflare resources were deleted.
| 1 | npm run build |
| 2 | npx wrangler pages deploy dist \ |
| 3 | --project-name example-site \ |
| 4 | --branch main \ |
| 5 | --commit-dirty=true |
| 6 | |
| 7 | # Delete only the temporary event resources, not the site itself. |
| 8 | npx wrangler delete --name my-event-rsvp --force |
| 9 | npx wrangler d1 delete my-event-rsvps --skip-confirmation |
| 10 | |
| 11 | # Verify the public artifacts no longer expose private event data. |
| 12 | curl -sSL https://example.com/events/my_event | |
| 13 | rg "address|rsvp|email|calendar|guest" -i && exit 1 || true |
| 14 | |
| 15 | curl -sSI "https://example.com/events/my-event.ics?key=example" | |
| 16 | rg "404|410" |
What I Would Repeat
- Build the invitation as a page first, and the RSVP machinery second.
- Use a temporary database only if the form needs to be custom.
- Keep every piece of private data on a deletion path from the beginning.
- Make post-event thank-you cards explicit when AI helped write them.
- End by verifying the built output and the live route with text searches, not just by looking at the page.
The final artifact is intentionally smaller than the system that made it: a quiet note, a few anonymous garden images, and no live RSVP infrastructure left behind. That is the point of a one-weekend site. It should serve the weekend, then step out of the way.