Skip to content
HoginHogin
Go back

A contact form on a static site: Cloudflare Turnstile + Pages Functions + D1 + Telegram — no backend of your own

9 мин чтения

A static site can only serve files: nobody is around to handle a POST. This week a live contact form shipped on hogin.pro: Turnstile against bots, server-side token verification, a D1 archive and an instant Telegram push. The whole “backend” is one TypeScript file in a functions/ directory, the site stayed static, the Cloudflare bill stayed at zero. Step by step below, including two traps that cost more than all the code.

Table of contents

Open Table of contents

The problem: static has nothing to answer a POST with

A built Astro blog is just files in dist/: nobody to process a POST. The usual options:

A form must accept a POST, tell a bot from a human, store the message, ping the author. Cloudflare’s free tier covers it all: 100,000 Functions requests/day, D1 and Turnstile free.

The whole server side is one file in functions/

Pages has an underrated feature: the functions/ directory next to the static assets deploys as Workers. The file functions/api/contact.ts becomes the route POST /api/contact — no own router, no Astro SSR adapter, no separate deploy. The site stays static, the “backend” is a file in the repo.

The message path: from the button to your phone

Three layers of bot protection

An open form lives for hours: bots find the endpoint, the spam flood starts. A request passes three filters before the database.

Three filters between a bot and the database

D1 is the source of truth, Telegram is best-effort

D1 is mandatory: if the INSERT fails, the client gets a 500 and the user retries. Telegram is a notification on top: if sendMessage fails, a warning goes to the log, but the client still gets a 200 OK — the message is already in the database, the push can be retried by hand.

D1 is the source of truth, Telegram is best-effort

This split kills “Telegram is down, so a reader could not write in”: a notification must never have the right to lose a message.

A Cloudflare form against the usual options

Formspree & SaaSVPS + nginx + phpPages + D1 + Telegram
Pricedozens of submissions/mo, then ~$20$4–6/mo + patching time$0: everything in the free tier
You maintainnothing, but the form is someone else’sOS, TLS, PHP, monitoringnothing: the code is in the repo
Bot protectionthe service’s filterinstall your ownhoneypot + Turnstile + siteverify
Messagesin the SaaS cabinetin your DBin your D1, one SQL query away

What you need to assemble the form in one evening

You need an Astro site on Pages and wrangler (npm i -g wrangler). The order:

1. The Turnstile widget. Dashboard: Turnstile → Add site → Managed. You get a sitekey (public) and a secret.

2. A Telegram bot. @BotFather — /newbot, a token like 123456789:AA…. Your numeric ID comes from @userinfobot; press /start on your bot — until the dialogue has begun, Telegram forbids the bot from writing first.

3. The D1 database and a table:

wrangler d1 create hogin-forms   # note the database_id
-- migrations/0001_init.sql
CREATE TABLE IF NOT EXISTS messages (
  id         TEXT PRIMARY KEY,
  name       TEXT NOT NULL,
  email      TEXT NOT NULL,
  message    TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
wrangler d1 execute hogin-forms --remote --file migrations/0001_init.sql

4. The form on an Astro page — fields, honeypot and widget:

---
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
---
<form id="contact-form" method="post" action="/api/contact">
  <input type="text" name="name" maxlength="100" required>
  <input type="email" name="email" maxlength="200" required>
  <textarea name="message" maxlength="5000" required></textarea>

  <!-- honeypot: hidden from humans, visible to bots -->
  <input type="text" name="website" tabindex="-1" autocomplete="off"
         class="hp" aria-hidden="true">

  <div class="cf-turnstile" data-sitekey={siteKey}></div>
  <button type="submit">Send</button>
</form>

<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
  document.getElementById("contact-form")?.addEventListener("submit", async (e) => {
    e.preventDefault();
    const form = e.currentTarget as HTMLFormElement;
    const { ok } = await (await fetch(form.action, {
      method: "POST", body: new FormData(form),
    })).json();
    form.reset();
    (window as any).turnstile?.reset();   // tokens are single-use — ask for a new one
  });
</script>

5. The entire “backend” — functions/api/contact.ts:

interface Env {
  DB: D1Database;
  TURNSTILE_SECRET: string;
  TG_BOT_TOKEN: string;
  TG_CHAT_ID: string;
}

export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
  const form = await request.formData();
  const name = String(form.get("name") ?? "").trim();
  const email = String(form.get("email") ?? "").trim();
  const message = String(form.get("message") ?? "").trim();
  const honeypot = String(form.get("website") ?? "");
  const token = String(form.get("cf-turnstile-response") ?? "");

  // filter 1: honeypot filled — fake success, nothing stored
  if (honeypot) return Response.json({ ok: true });

  // filter 2: length limits, email format
  if (!name || name.length > 100)
    return Response.json({ ok: false, error: "name" }, { status: 400 });
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) || email.length > 200)
    return Response.json({ ok: false, error: "email" }, { status: 400 });
  if (message.length < 5 || message.length > 5000)
    return Response.json({ ok: false, error: "message" }, { status: 400 });

  // filter 3: server-side Turnstile token check
  const verify = new URLSearchParams({ secret: env.TURNSTILE_SECRET, response: token });
  const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify",
    { method: "POST", body: verify });
  const { success } = await check.json<{ success: boolean }>();
  if (!success)
    return Response.json({ ok: false, error: "captcha" }, { status: 403 });

  // D1 is the source of truth: failure here = honest client error
  await env.DB.prepare(
    `INSERT INTO messages (id, name, email, message) VALUES (?1, ?2, ?3, ?4)`,
  ).bind(crypto.randomUUID(), name, email, message).run();

  // Telegram is best-effort: failure loses nothing, never bothers the user
  try {
    await fetch(`https://api.telegram.org/bot${env.TG_BOT_TOKEN}/sendMessage`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        chat_id: env.TG_CHAT_ID,
        text: `📩 ${name} <${email}>\n\n${message}`,
      }),
    });
  } catch (e) {
    console.warn("telegram send failed:", e);
  }

  return Response.json({ ok: true });
};

6. Variables in Pages (Settings → Variables and Secrets):

VariableTypeWhy
PUBLIC_TURNSTILE_SITE_KEYPlain textinto the build, public
TURNSTILE_SECRETSecretsiteverify at runtime
TG_BOT_TOKENSecretsending to Telegram
TG_CHAT_IDPlain textyour ID, not a secret

Plus Settings → Functions → D1 bindings: DB → hogin-forms (Production). A push to main ships static site and function in one deploy.

The traps we caught for you

Trap 1: a secret never reaches the build. Astro needs PUBLIC_TURNSTILE_SITE_KEY at build time; Secrets only reach the function runtime. Declare the sitekey as a Secret and the build silently substitutes nothing: the widget hangs with a “testing only” plaque, submissions fail siteverify. Cure: Plain text plus a Redeploy.

Trap 2: chat_id is not the bot. In a token 123456789:AA… the number before the colon is the bot’s own ID; put it into chat_id and you get 403 Forbidden: the bot can't send messages to the bot. The correct chat_id is your numeric ID; the bot must be “started” via /start (step 2).

Hunting blind is pointless — live logs save the day:

wrangler pages deployment tail <deployment-id>   # real-time deployment logs

Both were found with this exact command: the empty sitekey with a failing siteverify, and Telegram’s 403 with an exhaustive message. For history, enable Observability — Pages keeps logs even when nobody listens.

How to verify it all works

Locally — a smoke test without real keys: Turnstile ships test sitekeys (1x000…AA passes, 2x000… fails) and matching secrets:

pnpm build && wrangler pages dev dist   # function and static site on localhost:8788

A control submission:

# 1. without a Turnstile token — refused
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://example.com/api/contact \
  -d 'name=Test&[email protected]&message=hello world'    # 403

# 2. the message — in the database
wrangler d1 execute hogin-forms --remote \
  --command "SELECT id, name, email, created_at FROM messages ORDER BY created_at DESC LIMIT 5;"

# 3. the push hit the phone

Bottom line

A static blog needs no backend to hear from a reader: all the server logic is one file in functions/, execution and storage are free. The main discovery: the hard parts were in configuration, not code — the variable type (secret vs plain) and the bot’s chat_id cost more time than the entire form. The code takes an hour; read what Cloudflare writes in the logs. Since a form opens yet another entrance, a WAF beside it does not hurt — we covered a self-hosted option separately.


Share this post:

Next Post
The same architecture, self-hosted: rebuilding the part-1 AWS serverless diagram on your own VM for $0/mo (part 2/2)