Building a charity site on Astro and Cloudflare
/ 4 min read
Table of Contents
Every December a small square in Weesp fills up with kids selling hot chocolate, hand-painted ornaments, and slightly wonky gingerbread — the Kinderkerstmarkt Gouwplein, a neighbourhood Christmas market run by volunteers, proceeds going to charity. It needed a website: somewhere parents could register a stall, volunteers and sponsors could sign up, and visitors could find the date.
The design goals were simple: free to run, nothing to patch, and manageable by two non-technical volunteers after handover. This post walks through exactly how it’s built.
Architecture at a glance
flowchart TB
V["👤 Visitor browser"] --> SITE["Astro static site<br/>on Cloudflare Pages"]
SITE --> FORM["7 forms + Turnstile widget"]
FORM -->|"POST /submit"| FN["Pages Function<br/>functions/submit.ts"]
FN --> SHEET[("Google Sheets<br/>one tab per form")]
FN --> RESEND["Resend email API"]
RESEND --> CONF["✉️ Confirmation → submitter"]
RESEND --> NOTIF["✉️ Notification → organisers"]
Everything runs on free tiers, and there’s no server or database to maintain — the “admin panel” is a Google Sheet the volunteers already know how to use.
The static front-end: Astro
The site is Astro, built static with zero JavaScript by default — just HTML and CSS for ten pages (home, practical info, the various sign-up forms, FAQ, privacy). Design tokens live in a Tailwind @theme block, and content lives in editable content/*.yml files loaded at build time, so the organisers can tweak copy without touching components.
The only dynamic feature is form submission, which posts to a single endpoint.
The form backend: Cloudflare Pages Functions
All seven forms (stall registration, volunteer, sponsor, choir/music, food, trees/decoration, contact) post to one Pages Function at functions/submit.ts. A shared schema defines each form’s fields, required values, target sheet tab, and confirmation email — one source of truth driving validation, storage, and email.
The request flow:
flowchart TB A["Form POST /submit"] --> B["Honeypot check"] B --> C["Turnstile verification"] C --> D["Validate required fields"] D --> E["Append row to Google Sheet"] E --> F["Send confirmation to submitter"] E --> G["Send notification to organisers"] F --> H["✅ Success response"] G --> H
The Sheet write is the durable record and must succeed; the two emails are best-effort, so a submission is never lost if email hiccups.
The datastore: Google Sheets
Instead of a database, each submission becomes a row in a Google Sheet — one tab per form type. The function authenticates as a service account, signing a JWT with WebCrypto (RS256) in the Workers runtime, so there are no Node dependencies.
Setup:
- Create a Sheet with one tab per form, headers in row 1 (
Tijdstip+ the field labels). - In Google Cloud Console: a project, Google Sheets API enabled, a service account with a downloaded JSON key.
- Share the Sheet with the service account’s
client_emailas Editor.
From the JSON you get GOOGLE_SERVICE_ACCOUNT_EMAIL and GOOGLE_PRIVATE_KEY; the Sheet URL gives GOOGLE_SHEET_ID.
Email: Resend + Cloudflare Email Routing
Outbound transactional email goes through Resend (verify the domain via DNS records, create an API key). Two messages per submission: a confirmation to the person who submitted, and a notification to the organisers.
For inbound, Cloudflare Email Routing (free) forwards hoi@gouwplein.nl to the organisers’ shared Gmail inbox. To reply as hoi@ from that inbox, Gmail’s “Send mail as” is configured with Resend’s SMTP (smtp.resend.com:587, user resend, password = the Resend API key). The public only ever sees hoi@gouwplein.nl.
Spam protection: Turnstile + honeypot
Every form includes a Cloudflare Turnstile widget, and the function verifies the token server-side. A hidden honeypot field catches simple bots (filled in → silently accepted, stored nowhere). Turnstile uses a site key (public, in the page) and a secret key (server-side).
Deploying on Cloudflare
The repo is connected via Workers Builds: a build runs npm run build, then deploys to a Cloudflare Pages project that serves the static site and the functions together. The build and deploy commands:
# buildnpm run build# deploy to productionnpx wrangler pages deploy --branch=mainwrangler.toml declares the output directory, so no extra config is needed:
name = "kinderkerstmarkt-gouwplein"pages_build_output_dir = "dist"compatibility_date = "2024-09-23"Configuration splits into two halves — build-time variables (inlined by Astro during the build) and runtime secrets (read by the function per request):
flowchart TB subgraph BUILD["🔨 Build-time variables"] PUB["PUBLIC_TURNSTILE_SITE_KEY"] --> ASTRO["astro build → static dist/"] end subgraph RUN["⚡ Runtime secrets"] SEC["GOOGLE_SERVICE_ACCOUNT_EMAIL<br/>GOOGLE_PRIVATE_KEY · GOOGLE_SHEET_ID<br/>RESEND_API_KEY · RESEND_FROM<br/>NOTIFY_EMAIL · TURNSTILE_SECRET_KEY"] --> FN["functions/submit.ts"] end ASTRO --> SITE["🌐 gouwplein.nl"] FN --> SITE
| Variable | Type | Purpose |
|---|---|---|
PUBLIC_TURNSTILE_SITE_KEY | build-time | renders the Turnstile widget in the HTML |
TURNSTILE_SECRET_KEY | runtime | server-side token verification |
GOOGLE_SERVICE_ACCOUNT_EMAIL | runtime | service-account identity |
GOOGLE_PRIVATE_KEY | runtime | signs the Google JWT |
GOOGLE_SHEET_ID | runtime | target spreadsheet |
RESEND_API_KEY | runtime | sending email |
RESEND_FROM | runtime | the hoi@gouwplein.nl sender |
NOTIFY_EMAIL | runtime | organiser notifications |
Finally, the custom domain gouwplein.nl (and www) is attached to the Pages project for automatic SSL. A push to main now builds and deploys to production on its own.
The result
- A live site at gouwplein.nl, all seven forms writing to their own sheet tabs with confirmation and notification emails.
- €0/month — every component sits on a free tier.
- No infrastructure to maintain — no server, no database, no CMS.
- An admin experience that’s just a spreadsheet two volunteers already understand.
Exactly the brief: cheap, simple, and easy to hand off.