Astro Contact Forms with Cloudflare Pages Functions

A contact form for a static Astro site on Cloudflare Pages: a Pages Function that sends mail through a transactional email API, with escaping, spam protection and local testing.

Astro contact form code with a Cloudflare Pages Function handler sending email through a transactional email API

A static Astro site has no server to send email, and a contact form needs one small piece of server-side code: something that receives the form and hands the message to an email service. On Cloudflare Pages, a Pages Function does that without running a server.

You still need an email-sending service. The free, account-less MailChannels integration that many older guides rely on ended on 31 August 2024 (MailChannels’ notice). This guide uses Brevo’s transactional email API, whose free plan allows 300 emails a day, which is what linuxcore.dev’s own form uses. MailChannels’ current Email API (free plan with an account) or Resend work the same way with a different endpoint and payload.

How It Works

Browser form  →  POST /api/contact  →  Pages Function  →  Brevo API  →  your inbox

The function validates the input, escapes it, and sends one email to you with the visitor’s address as replyTo.

Project Structure

functions/
  api/
    contact.ts        ← the Pages Function (served at /api/contact)
src/
  pages/
    contact.astro     ← the form

Pages detects the functions/ directory at the project root and deploys each file as a function; the path maps to the URL.

Step 1: Create the Pages Function

// functions/api/contact.ts
interface Env {
  BREVO_API_KEY: string;
  CONTACT_FROM: string;      // a sender address you verified in Brevo
  CONTACT_TO_EMAIL: string;  // where messages should arrive
}

// Never put user input into HTML unescaped
const esc = (v: string) =>
  v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
   .replace(/"/g, '&quot;').replace(/'/g, '&#39;');

export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
  let data: Record<string, string>;
  try {
    data = await request.json();
  } catch {
    return Response.json({ error: 'Invalid request.' }, { status: 400 });
  }

  const name = (data.name ?? '').trim().slice(0, 200);
  const email = (data.email ?? '').trim().slice(0, 200);
  const subject = (data.subject ?? '').trim().slice(0, 200);
  const message = (data.message ?? '').trim().slice(0, 5000);

  if (data.website) return Response.json({ ok: true }); // honeypot: bots fill hidden fields
  if (!name || !subject || !message || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return Response.json({ error: 'Please fill in all fields with a valid email.' }, { status: 400 });
  }

  const res = await fetch('https://api.brevo.com/v3/smtp/email', {
    method: 'POST',
    headers: { 'api-key': env.BREVO_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      sender: { name: 'Contact form', email: env.CONTACT_FROM },
      to: [{ email: env.CONTACT_TO_EMAIL }],
      replyTo: { email, name },
      subject: `[contact] ${subject.replace(/[\r\n]/g, ' ')}`,
      htmlContent: `<p><b>${esc(name)}</b> &lt;${esc(email)}&gt;</p><pre>${esc(message)}</pre>`,
    }),
  });

  if (!res.ok) {
    console.error('Brevo error:', res.status, await res.text());
    return Response.json({ error: 'Sending failed, please try again later.' }, { status: 502 });
  }
  return Response.json({ ok: true });
};

The form posts from your own domain, so the function doesn’t need any CORS headers.

Step 2: Set the Secrets

In the Cloudflare dashboard: Workers & Pages → your project → Settings → Variables and Secrets, add BREVO_API_KEY (as a secret), CONTACT_FROM and CONTACT_TO_EMAIL for the Production environment, then redeploy.

In Brevo, verify the sender address or, better, authenticate your whole sending domain (Brevo’s domain settings give you the DKIM and DMARC records to publish). That’s what keeps the mail out of spam folders.

Step 3: Protect the Form

  • Honeypot: the function above silently accepts submissions that fill a hidden website field. Add that field to the form, hidden with CSS.
  • Turnstile: for real protection, add Cloudflare Turnstile to the form and verify its token in the function (a POST to Turnstile’s siteverify endpoint with your secret key) before calling Brevo.

Step 4: Build the Contact Form Page

Create src/pages/contact.astro:

---
import BaseLayout from '../layouts/BaseLayout.astro';
---

<BaseLayout
  title="Contact"
  description="Get in touch with linuxcore.dev"
>
  <main class="contact-page">
    <div class="contact-inner">

      <div class="page-header">
        <div class="header-label">// CONTACT</div>
        <h1 class="page-title">Get in touch</h1>
        <p class="page-lead">
          Questions, feedback, or content ideas. I read everything.
          For technical questions on specific articles, the comments section is usually faster.
        </p>
      </div>

      <form class="contact-form" id="contact-form" novalidate>
        <!-- honeypot: hidden from people, often filled in by bots -->
        <input type="text" name="website" id="website" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true" />
        <div class="field">
          <label for="name">Name</label>
          <input type="text" id="name" name="name" required placeholder="Your name" />
        </div>

        <div class="field">
          <label for="email">Email</label>
          <input type="email" id="email" name="email" required placeholder="your@email.com" />
        </div>

        <div class="field">
          <label for="subject">Subject</label>
          <input type="text" id="subject" name="subject" required placeholder="What's this about?" />
        </div>

        <div class="field">
          <label for="message">
            Message
            <span id="char-count" class="char-count">0 / 2000</span>
          </label>
          <textarea id="message" name="message" required maxlength="2000" rows="7" placeholder="Your message..."></textarea>
        </div>

        <div id="form-status" class="form-status" role="alert" aria-live="polite"></div>

        <button type="submit" id="submit-btn" class="btn-primary">
          Send Message →
        </button>
      </form>

    </div>
  </main>
</BaseLayout>

<style>
  .contact-page { max-width: 1100px; margin: 0 auto; padding: 3rem 40px 5rem; }
  .contact-inner { max-width: 560px; }

  .header-label { font-family: var(--mono); font-size: 10px; letter-spacing: 0.2em; color: var(--amber); margin-bottom: 10px; }
  .header-label::before { content: '// '; color: var(--text-dim); }
  .page-title { font-family: var(--display); font-size: clamp(28px, 4vw, 42px); font-weight: 600; color: var(--text-bright); margin-bottom: 12px; }
  .page-lead { font-size: 16px; color: var(--text-mid); line-height: 1.7; margin-bottom: 2.5rem; }

  .contact-form { display: flex; flex-direction: column; gap: 1.5rem; }

  .field { display: flex; flex-direction: column; gap: 6px; }

  .field label {
    font-family: var(--mono); font-size: 10px;
    color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.1em;
    display: flex; justify-content: space-between; align-items: center;
  }

  .char-count { font-size: 10px; color: var(--text-dim); font-family: var(--mono); }

  .field input, .field textarea {
    background: var(--bg2); border: 1px solid var(--border-mid);
    color: var(--text); font-family: var(--sans); font-size: 15px;
    padding: 10px 14px; outline: none; width: 100%;
    transition: border-color 0.15s; resize: vertical;
  }
  .field input:focus, .field textarea:focus { border-color: var(--amber); }
  .field input::placeholder, .field textarea::placeholder { color: var(--text-dim); }

  .form-status {
    font-family: var(--mono); font-size: 13px;
    padding: 10px 14px; display: none;
  }
  .form-status.success { display: block; background: rgba(78,202,139,0.08); border: 1px solid rgba(78,202,139,0.25); color: var(--green); }
  .form-status.error   { display: block; background: rgba(255,107,107,0.08); border: 1px solid rgba(255,107,107,0.25); color: var(--red); }
</style>

<script>
  const form    = document.getElementById('contact-form') as HTMLFormElement;
  const btn     = document.getElementById('submit-btn') as HTMLButtonElement;
  const status  = document.getElementById('form-status') as HTMLDivElement;
  const message = document.getElementById('message') as HTMLTextAreaElement;
  const counter = document.getElementById('char-count') as HTMLSpanElement;

  message?.addEventListener('input', () => {
    counter.textContent = `${message.value.length} / 2000`;
  });

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

    btn.disabled = true;
    btn.textContent = 'Sending…';
    status.className = 'form-status';
    status.textContent = '';

    try {
      const res = await fetch('/api/contact', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name:    (document.getElementById('name') as HTMLInputElement).value,
          email:   (document.getElementById('email') as HTMLInputElement).value,
          subject: (document.getElementById('subject') as HTMLInputElement).value,
          message: message.value,
          website: (document.getElementById('website') as HTMLInputElement).value,
        }),
      });

      const data = await res.json() as { ok?: boolean; error?: string };

      if (data.ok) {
        status.className = 'form-status success';
        status.textContent = '✓ Message sent — I\'ll get back to you soon.';
        form.reset();
        counter.textContent = '0 / 2000';
      } else {
        throw new Error(data.error ?? 'Unexpected error');
      }
    } catch (err: any) {
      status.className = 'form-status error';
      status.textContent = `✕ ${err.message ?? 'Something went wrong. Try again or email directly.'}`;
    } finally {
      btn.disabled = false;
      btn.textContent = 'Send Message →';
    }
  });
</script>

Testing Locally

The Astro dev server doesn’t run Pages Functions. Build the site and serve it with Wrangler, which does:

npm run build
npx wrangler pages dev dist

Wrangler reads local secrets from a .dev.vars file in the project root (not .env), in KEY=value lines. Keep that file out of git. Or push a non-main branch and test on the preview deployment, after adding the variables for the Preview environment too.


Troubleshooting

Emails land in spam: authenticate your sending domain in Brevo (DKIM, and a DMARC record), and use a sender address on that domain.

The function returns 502: check the function logs in the Cloudflare dashboard (your project → Deployments → the deployment → Functions) for the provider’s error message: often an unverified sender or a wrong API key.

The form submits but nothing arrives: confirm the variables are set for the environment you’re testing (Production vs Preview) and redeploy after changing them.


The End Result

A working contact form with no form service in between and, within the email provider’s free tier, no monthly bill. The function runs on Cloudflare’s edge network, the same infrastructure serving your static pages.

The setup is one function, one form and a few DNS records for your sending domain.


Next in this series: Building a Shop in Astro with Printify and Stripe, print-on-demand merch with Cloudflare checkout.

Frequently Asked Questions

Is MailChannels still free on Cloudflare?
Not in the old way. The free, account-less MailChannels sending from Cloudflare Workers and Pages ended on 31 August 2024; since then MailChannels rejects unauthenticated requests. Its Email API has a free plan (100 emails per day at the time of the change) that needs an account and an API key.
Do I need a backend server for this?
No. A Cloudflare Pages Function runs the server-side part at the edge. The form posts to it, and it calls the email provider's API.
How do I stop spam submissions?
Add Cloudflare Turnstile to the form and verify its token in the function before sending, and keep a simple honeypot field. Rate limits on your email provider also cap the damage.
Does this work with frameworks other than Astro?
Yes. The Pages Function is independent of the frontend: any static site on Cloudflare Pages can post to it.

Get notified when new articles and designs land:

No spam. Unsubscribe any time.

Sergej Voronko
Sergej Voronko
SAP Basis · Senior Operations Manager · Linux infrastructure engineer
About the author →

[discussion]

Comments are powered by Giscus — backed by GitHub Discussions. Sign in with GitHub to join the conversation.