DEV Community

Cover image for How to add a contact form to an Astro site without a backend
Tilek Kubanov
Tilek Kubanov

Posted on Originally published at catchform.dev AI-assisted

How to add a contact form to an Astro site without a backend

You build an Astro site. It's fast, it's static, it deploys anywhere. Then someone asks for a contact form and you remember that <form> needs somewhere to POST — and you don't have a server.

Here are the four options that actually exist, when each one is right, and the details nobody mentions until they bite you.

Option 1: mailto: — don't

<form action="mailto:you@example.com" method="POST">
Enter fullscreen mode Exit fullscreen mode

It seems to work on your machine. In the wild it opens whatever the visitor's browser thinks is their mail client, which for most people is nothing at all. They see a broken page or a download prompt, and you never learn that they tried. It also publishes your address to every scraper on the internet.

Skip it.

Option 2: Astro SSR with an API route

Astro can run on a server. Add an adapter, switch the route to server-rendered, and you can handle the POST yourself:

// src/pages/api/contact.js
export const prerender = false;

export async function POST({ request }) {
  const data = await request.formData();

  // ...validate, then send mail with your provider of choice
  await sendMail({
    to: 'you@example.com',
    subject: 'New contact form submission',
    text: `${data.get('email')}\n\n${data.get('message')}`,
  });

  return new Response(null, { status: 303, headers: { Location: '/thanks' } });
}
Enter fullscreen mode Exit fullscreen mode

When this is right: you already run the site with an adapter, and you want the submission to touch your own database or business logic.

What it costs: a host that runs a server, an email provider account and its API key, deliverability setup (SPF, DKIM, DMARC — get these wrong and your mail silently lands in spam), spam filtering, and somewhere to store submissions when the mail fails. That's a weekend, not an afternoon, and it's a weekend you spend again on the next site.

Option 3: A serverless function

Netlify Functions, Vercel Functions, Cloudflare Workers. Same code as above, no server to keep alive.

When this is right: you're already on that platform and comfortable there.

What it costs: everything from option 2 except the server, plus a tie to that platform. Move the site to a different host and the form stops working with it.

Option 4: A hosted form backend

Point the form's action at someone else's URL. They receive the POST, store it, email you, and give you an inbox. No server, no API key, no deliverability setup.

There are plenty: Formspree, Basin, Formcarry, Web3Forms, Netlify Forms if you're on Netlify. I build CatchForm, so that's what the code below uses — the shape is the same whichever you pick.

When this is right: the form is a contact form. If submissions just need to reach a human, running infrastructure for that is a hobby, not a requirement.


The rest of this post is option 4 done properly in Astro.

The component

Start with the version that works when JavaScript doesn't. Astro is good at this — no client-side JS at all:

---
// src/components/ContactForm.astro
const endpoint = `https://catchform.dev/f/${import.meta.env.PUBLIC_FORM_TOKEN}`;
---

<form action={endpoint} method="POST" class="contact">
  <!-- Honeypot. Real people never fill a hidden field; bots fill everything.
       Keep it empty and unlabelled, and keep it out of the tab order. -->
  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" hidden />

  <label for="email">Your email</label>
  <input id="email" type="email" name="email" required autocomplete="email" />

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" required></textarea>

  <button type="submit">Send</button>
</form>
Enter fullscreen mode Exit fullscreen mode

That is a working contact form. The browser posts it, the backend stores it, you get an email. It works with JS disabled, on a slow phone, in a text browser.

Three things worth noticing:

The token goes in the client. It has to — the browser is doing the POST. PUBLIC_ is Astro's marker for exactly this. The token identifies which form the submission belongs to; it isn't a secret and can't read anything.

Field names are yours. There's no schema to configure. Whatever your HTML sends is what gets stored, so renaming message to enquiry needs no change anywhere else.

The honeypot is a real input, not a comment. hidden keeps it off the screen, tabindex="-1" keeps keyboard users out of it, autocomplete="off" keeps the browser from helpfully filling it in — that last one matters, because a password manager that fills every field will get your visitor's message rejected as spam.

Upgrading to fetch()

Full page navigation is fine, but you probably want to keep the visitor on the page. Add JS as an enhancement, so the form still works without it:

<script>
  const form = document.querySelector<HTMLFormElement>('.contact');
  const status = document.querySelector<HTMLParagraphElement>('#form-status');

  form?.addEventListener('submit', async (event) => {
    event.preventDefault();

    const button = form.querySelector('button');
    button.disabled = true;
    status.textContent = 'Sending…';

    try {
      const response = await fetch(form.action, {
        method: 'POST',
        headers: { Accept: 'application/json' },
        body: new FormData(form),
      });

      // 422 means the honeypot caught it. Show the same thank-you: telling a
      // bot it was rejected only teaches it what to change. If real people are
      // hitting this, your honeypot is being autofilled — see autocomplete="off".
      if (response.ok || response.status === 422) {
        form.reset();
        status.textContent = 'Thanks — I will get back to you.';
      } else {
        const body = await response.json().catch(() => ({}));
        status.textContent = body.error ?? 'Something went wrong. Please try again.';
      }
    } catch {
      status.textContent = 'Could not reach the server. Please try again.';
    } finally {
      button.disabled = false;
    }
  });
</script>

<p id="form-status" role="status" aria-live="polite"></p>
Enter fullscreen mode Exit fullscreen mode

role="status" with aria-live="polite" matters more than it looks: without it a screen reader user gets no feedback at all, because visually the only thing that changed is a paragraph they never focus.

The gotcha that will cost you an hour

If your form has a redirect URL configured, fetch() won't see JSON. The backend answers a successful submission with a 302 to that URL, fetch follows it, and response.ok is true for a page you never wanted. You get JSON only when no redirect is configured.

So pick one, deliberately: a redirect URL for the no-JavaScript flow, or an empty redirect and JSON for the fetch flow. If you want both — the plain form as a fallback and JS on top — leave the redirect empty and handle the success state yourself in both paths, with the <noscript> case landing on the JSON response.

The other one

Check that your backend actually sends CORS headers. A cross-origin fetch needs Access-Control-Allow-Origin on the response, and a simple form POST doesn't. If they're missing, the failure is nasty: the request goes through and your submission is stored, but the browser refuses to let JS read the response, so your visitor sees an error and sends it again. You get duplicates and nobody understands why.

Test it before you ship:

curl -i -X OPTIONS https://your-backend.example/your-endpoint \
  -H "Origin: https://yoursite.example" \
  -H "Access-Control-Request-Method: POST"
Enter fullscreen mode Exit fullscreen mode

You want access-control-allow-origin in the output. (I found this missing in my own service while writing this post. It's fixed now — but check yours.)

Handling the response

Whatever backend you use, decide what happens on each outcome before your visitors find out for you:

What happened Typical response What the visitor should see
Accepted 200 with a JSON body a thank-you, and a cleared form
Caught as spam 422 the same thank-you — never tell a bot it was caught
Over the monthly quota 429 "we could not accept this right now", and an email to you
Bad or inactive token 404 a generic failure, plus an alert to yourself

That third row is the one people skip. On a free plan the quota is not theoretical, and a form that silently stops accepting messages is worse than no form.

Where to put the token

# .env
PUBLIC_FORM_TOKEN=your-token
Enter fullscreen mode Exit fullscreen mode
// src/env.d.ts — so a typo becomes a build error, not a broken form
interface ImportMetaEnv {
  readonly PUBLIC_FORM_TOKEN: string;
}
Enter fullscreen mode Exit fullscreen mode

And add the variable to your host's build settings. Forgetting that is the single most common way this breaks in production: import.meta.env.PUBLIC_FORM_TOKEN becomes undefined, the action becomes .../f/undefined, and every submission 404s. Guard it if you like:

---
const token = import.meta.env.PUBLIC_FORM_TOKEN;
if (!token) throw new Error('PUBLIC_FORM_TOKEN is not set');
---
Enter fullscreen mode Exit fullscreen mode

An Astro build that fails loudly beats a deployed form that fails quietly.

Which option should you pick

If the form's job is to get a message to a person: option 4, and spend the afternoon on something else.

If the submission has to touch your own data — create a record, check stock, trigger a workflow — write the endpoint yourself. Options 2 and 3 exist for that, and a form backend would only be in the way.

If you're already on Netlify and never plan to leave: Netlify Forms is right there and costs you nothing extra to try.


I build CatchForm, the form backend used in the examples — free plan is 1 form and 100 submissions a month, no card. The Astro-specific version of this guide lives here. Happy to answer questions about any of the four options in the comments, including the ones that don't involve me.

Top comments (0)