Wunderlandmedia

How to Add a Contact Form to a Static Astro Site (Without SSR)

Static Astro sites have no backend, but they still need forms. Here's the shared-backend setup I use: spam-filtered, stored, and emailed.

Kemal Esensoy·Modified on August 17, 2026

How to Add a Contact Form to a Static Astro Site (Without SSR)
Insights & Ideas

Astro's whole pitch is that there's nothing to break. You build the site, it spits out a folder of plain HTML, you put that behind a CDN, and you're done. No server running at 3am. Nothing to patch. Nothing to get hacked.

Then the client emails: "Can we add a contact form?"

And that clean story hits a wall. A form has to submit somewhere, and a static site has no somewhere. I've now built the same Astro contact form setup for a stack of client sites, and the first two times I did it wrong in two different ways. Here's the version I wish someone had handed me on day one.

Why Static Astro Sites Can't Handle Contact Forms on Their Own

A static site generator like Astro renders everything to HTML at build time. There's no PHP, no Node process, no database sitting behind the page waiting for a POST. That's exactly why it's fast and cheap and hard to attack. It's also why the second you need to receive data from a visitor, the model breaks.

A form is dynamic by definition. Someone types their name, hits submit, and that data has to go to a server that validates it, stores it, and emails you. Your static site can't be that server. It's just files.

So the question isn't "how do I make Astro handle a form." Astro can't, and shouldn't. The question is "where does the dynamic part live, and how do I bolt it on without throwing away the reason I chose static in the first place." If you're still deciding between static Astro, Next.js, and something server-rendered, I wrote about how I actually pick the stack for each client project separately. This post assumes you've already committed to static and now need a form.

4 Ways to Add a Contact Form to Astro (and Why 3 of Them Backfire)

Almost everyone reaches for one of these four. I've tried three of them on real projects.

A Node or Express endpoint per site. Works great for site number one. By site number six you're maintaining six separate deployments, six sets of secrets, six things to patch when a dependency has a CVE. The form logic is byte-for-byte identical every time. Only the recipient email changes. Copy-pasting infrastructure like that isn't an architecture, it's a slow-motion liability.

Turning on SSR "just for the form." Now your entire site renders on a server. You've taken on an adapter, a runtime, cold starts, and a much bigger attack surface, all to handle a payload that shows up a few times a day. You just gave back the exact thing that made static worth choosing.

A form SaaS like Formspree or Basin. Genuinely fine to start, and I'd never talk someone out of it for a single site. But the per-form pricing stacks up fast across ten client sites, and depending on your jurisdiction and how sensitive the data is (a mortgage lead or a legal inquiry is not the same as a newsletter signup), routing everything through a third-party processor becomes a compliance conversation you might not want to have. This is the same reason I've moved a lot of my stack off rented services and onto things I self-host instead.

Client-side email tools like EmailJS. Your email provider credentials ship straight to the browser, where anyone can read them. Don't. That's the whole note.

The pattern that dodges all four problems at once: one shared form backend, many static sites.

The Shared Backend Architecture: One Form Service for Many Static Sites

Here's the shape of it. One small service. One database. Every site posts to it with its own ID baked into the URL.

[ site-a (static) ]  --POST-->  forms.example.com/submit/site-a/kontakt
[ site-b (static) ]  --POST-->  forms.example.com/submit/site-b/kontakt

                     forms.example.com  (one small service)
                          validate -> spam-check -> store -> email
                                  |
                          [ Postgres ]   [ SMTP provider ]

The trick is to hold three responsibilities apart in your head:

  • Render is the HTML the visitor sees. That's per-site, it lives on the static Astro build.
  • Transport is how the data leaves the browser. A native POST, or a fetch.
  • Handle is validate, spam-check, store, notify. That's the shared backend, solved once.

Render changes per client. Transport and Handle you build a single time. The whole backend is a couple hundred lines with one interesting route:

POST /submit/:siteId/:formId

Six separate form endpoints to maintain versus one shared backend service

The tenant is right there in the URL, so the service doesn't hold any state about which site is calling. The caller declares it, the service looks it up in a small config registry keyed by site ID, then by form ID:

export const sites = {
  'site-a': {
    allowedOrigins: ['https://site-a.example', 'https://www.site-a.example'],
    fromEmail: 'Site A <no-reply@site-a.example>', // verified sender
    forms: {
      kontakt: {
        recipients: ['leads@site-a.example'],
        required: ['name', 'email', 'nachricht'],
        altcha: true,
        redirectSuccess: 'https://site-a.example/danke/',
      },
    },
  },
};

Onboarding site number eleven is one object in that file plus a redeploy. The code never changes. That property, new tenant equals new config row and not a new deployment, is the entire reason this approach beats the copy-paste one.

How to Store Form Submissions Without Losing Leads

Two decisions here saved me real pain, so I want to be specific.

First, don't model a fixed column per form field. A callback form has a telefon field, a contact form has nachricht, the next client wants a budget dropdown. If every form shape needs a database migration, you'll hate your life by the third client. Store the envelope plus a JSON blob instead:

CREATE TABLE submissions (
  id          bigserial PRIMARY KEY,
  site_id     text NOT NULL,
  form_id     text NOT NULL,
  received_at timestamptz NOT NULL,
  ip          text,
  user_agent  text,
  fields      jsonb NOT NULL       -- whatever the form sent
);

site_id answers "which site was this" forever, and jsonb means a new form needs zero migrations. You can still query it: fields->>'email', index per site, all of it.

Second, and this is the one people skip: store the lead before you try to email it. Email is the flaky part. SMTP hiccups, a sender you forgot to verify, a provider rate limit. If your handler emails first and stores second, a mail failure means a lost customer. So flip it. Write to the database, and if that somehow fails, append the submission to a local NDJSON file as a failsafe. Only then attempt the email, and if that fails, log it loudly but still tell the visitor it worked, because their data is already safe:

let stored = false;
try { await db.insert(submission); stored = true; }
catch (e) { console.error('DB insert failed, using file fallback', e); }
if (!stored) await appendNdjson(submission);

try { await sendMail(submission); }
catch (e) { console.error('mail FAILED (lead is stored)', e); }

return respondSuccess();

A misconfigured sender becomes a line in your logs, not a customer who thinks they contacted you and never hears back. That failure mode is invisible and it's the worst one, because nobody complains. They just quietly go to a competitor.

How to Stop Contact Form Spam Without reCAPTCHA

There's no single magic filter. What works is a stack of cheap layers, each one killing off a class of bot, none of them annoying a real human.

Layered spam defense filtering bot submissions before they reach the inbox

  • Origin lock. The request's Origin header has to be in that tenant's allowedOrigins. Site A physically cannot spam through site B's config.
  • Honeypot. A hidden field a real user never sees. If it comes back filled, silently drop the submission and return success anyway. Never tell the bot it got caught, or it just adapts.
  • Rate limit. Something like 20 requests per 10 minutes per IP. A human sends one message. A bot farm does not.
  • Proof-of-work. The browser has to burn a tiny bit of CPU before the submit is accepted. Trivial for one honest visitor, expensive at spam scale.

That last one is where I use ALTCHA, and it's worth explaining why I picked it over Google's tool. reCAPTCHA drags in a third party, tracks your users, and hands Google another view into your traffic. I got deep into the alternatives in this piece on replacing reCAPTCHA, but the short version is ALTCHA is privacy-friendly, self-hostable, and has no requirement about where your DNS lives. For a contact form, its SHA-256 proof-of-work plus the honeypot plus rate limiting is already the right amount of defense.

Setting Up ALTCHA in Astro: The Version Mismatch That Breaks Everything

Here's the one that ate an entire afternoon, so let me save you the afternoon.

ALTCHA widget and server library version mismatch causing verification to fail

ALTCHA ships as two halves: a widget that runs in the browser, and a server library that verifies the solved token. They have to speak the same protocol version. The classic proof-of-work flow (server library v1, widget v1.x) drops a solved token into a hidden field and verifies it server-side. The newer v3 widget expects the v2 server protocol, which uses a different challenge shape. Mix a v3 widget with a v1 backend and you get this:

ALTCHA verification failed: Server responded with invalid content-type.
Expected application/json, received text/html.

That error is maddening, because your challenge endpoint is returning JSON. You can curl it and watch it return JSON. What's actually happening: the v3 widget, handed a v1 challenge it doesn't understand, falls through to a server-verification path against an empty URL, which resolves to your own page, which returns HTML. The content-type check then throws. You spend three hours debugging a JSON endpoint that was never the problem.

The fix is not to debug it. The fix is to match the versions. If your backend runs the v1 library (simple, perfect for a contact form), use the v1.x widget. If you specifically want v3's Argon2 hardening, upgrade the backend to the v2 library. And here's the judgment call: Argon2 hardening exists to slow down credential-stuffing on login forms. It is overkill for spam on a Kontaktformular. Don't reach for "latest" reflexively. Match the tool to the threat.

One more thing: self-host the widget. Vendor the altcha.min.js file straight into your Astro public/ folder. No CDN. It keeps your Content-Security-Policy simple, which matters in the next section.

Building the Astro Contact Form: Component, CSP, and Thank-You Page

The static half of an Astro contact form is small. A form component, the vendored widget, three CSP lines, and a thank-you page.

---
const BACKEND = 'https://forms.example.com';
const formAction = `${BACKEND}/submit/site-a/kontakt`;
const altchaUrl  = `${BACKEND}/altcha/challenge`;
---
<form method="post" action={formAction}>
  <label>Name <input name="name" required /></label>
  <label>E-Mail <input type="email" name="email" required /></label>
  <label>Nachricht <textarea name="nachricht" required></textarea></label>

  <!-- honeypot: off-screen, still submitted -->
  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
         style="position:absolute;left:-9999px" aria-hidden="true" />

  <label>
    <input type="checkbox" name="datenschutz" required />
    Ich habe die <a href="/datenschutz/">Datenschutzerklärung</a> gelesen.
  </label>

  <altcha-widget challengeurl={altchaUrl} auto="onsubmit"></altcha-widget>
  <button type="submit">Senden</button>
</form>

<script is:inline type="module" src="/altcha.min.js"></script>

A few things there are load-bearing. The name on each input has to match the backend's required[] list exactly. The auto="onsubmit" makes the widget solve its puzzle only when the user actually submits, so there's no visible friction until they act. The GDPR consent checkbox is table stakes for any German or EU site, it's required client-side and stored with the submission. And is:inline on the script tag stops Astro from trying to bundle the vendored widget.

Then the CSP lines everyone forgets. A strict Content-Security-Policy will silently block the widget and you'll have no idea why. Merge these into your existing policy:

connect-src 'self' https://forms.example.com;
form-action 'self' https://forms.example.com;
worker-src  'self' blob:;

The connect-src lets the widget fetch its challenge, form-action allows the native cross-origin POST, and worker-src lets the widget solve its proof-of-work in a Web Worker. If your form "just doesn't work" and the console shows a CSP violation, it's almost always one of those three. There's no external script-src needed, because you self-hosted the widget.

The thank-you page is a plain src/pages/danke/index.astro marked noindex. The native POST redirects there on success. Nothing fancy, just confirmation the message landed.

Deploying a Shared Form Backend: The Pitfalls That Actually Bite

These are the ones that cost me time on real deploys, not the theoretical ones.

tsc: not found during the Docker build. Plenty of platforms inject NODE_ENV=production at build time, which makes npm ci skip devDependencies, so TypeScript never installs. Fix: npm ci --include=dev in the builder stage.

Secrets baked into image layers. If your platform turns every env var into a build ARG, your database URL and SMTP password get baked into the image and anyone who pulls it can read them. Mark them runtime-only. This app needs nothing secret at build time. This is exactly the kind of thing on my website security hardening checklist that people miss until it bites.

A bundled database instead of a managed one. It's tempting to drop Postgres into the app's docker-compose. Don't. A stray compose down -v or an unlucky redeploy can wipe every lead you've collected, and now backups are your problem too. Use a managed database as a separate resource and connect over DATABASE_URL. The app's lifecycle and your data's lifecycle should never be the same thing.

Cloudflare error 526. If the domain proxies through Cloudflare in "Full (strict)" SSL mode but the origin has no valid certificate, every request 526s. Either issue a real cert on the origin or drop the mode to "Full."

How to Add a Contact Form to a New Astro Site in One Afternoon

Once the backend exists, here's the entire checklist for site number eleven:

  1. Backend: add the site to the config registry with its origins, from-address, forms, and recipients. Commit, push, redeploy the one service.
  2. Site: drop in the form component, vendor altcha.min.js into public/, add the three CSP lines, add a /danke/ page.
  3. Verify: build it, submit once in a real browser. The widget solves, you get the redirect, the email arrives, and the row shows up in Postgres tagged with the new site_id.

That's it. The hard part was designing it once. Every site after is an afternoon, and honestly most of that afternoon is styling the form to match the site.

Static generation and dynamic form handling were never actually in tension. You just put the dynamic part where it belongs, in one small shared service, instead of smearing it across every site or bolting a whole runtime onto your renderer.

If you're running a handful of static sites and the form situation has quietly turned into a maintenance headache, that's the exact problem I like solving. Come tell me what you're dealing with and I'll tell you honestly whether this setup is worth it for your case or whether a form SaaS is still fine for now.

About the Author

KE

Kemal Esensoy

Kemal Esensoy, founder of Wunderlandmedia, started his journey as a freelance web developer and designer. He conducted web design courses with over 3,000 students. Today, he leads an award-winning full-stack agency specializing in web development, SEO, and digital marketing.

Astro Contact Form for Static Sites | Wunderlandmedia