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, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
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> <${esc(email)}></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
websitefield. 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
siteverifyendpoint 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.
[discussion]
Comments are powered by Giscus — backed by GitHub Discussions. Sign in with GitHub to join the conversation.