skip to content
BitsAndBytes
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:

  1. Create a Sheet with one tab per form, headers in row 1 (Tijdstip + the field labels).
  2. In Google Cloud Console: a project, Google Sheets API enabled, a service account with a downloaded JSON key.
  3. Share the Sheet with the service account’s client_email as 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:

Terminal window
# build
npm run build
# deploy to production
npx wrangler pages deploy --branch=main

wrangler.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
VariableTypePurpose
PUBLIC_TURNSTILE_SITE_KEYbuild-timerenders the Turnstile widget in the HTML
TURNSTILE_SECRET_KEYruntimeserver-side token verification
GOOGLE_SERVICE_ACCOUNT_EMAILruntimeservice-account identity
GOOGLE_PRIVATE_KEYruntimesigns the Google JWT
GOOGLE_SHEET_IDruntimetarget spreadsheet
RESEND_API_KEYruntimesending email
RESEND_FROMruntimethe hoi@gouwplein.nl sender
NOTIFY_EMAILruntimeorganiser 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.