Every theme has a contact form (src/components/forms/ContactForm.astro) on the contact page. You choose where submissions go in src/config/integrations.ts; no code changes needed.
Pick a provider
| Provider | Best for | Setup |
|---|---|---|
formspree |
Most sites. Works on any host | Free account, paste a form ID |
formsubmit |
No account at all | Confirm your email once |
netlify |
Sites hosted on Netlify | Nothing, if you’re on Netlify |
cloudflare |
Sites on Cloudflare Pages that want no third-party form service | Two small files and some dashboard settings |
null or "mailto" |
Local demos only | Opens the visitor’s email app |
Formspree
- Create a form at formspree.io.
- Copy its ID (the part after
/f/).
contactForm: {
provider: "formspree",
formspreeId: "xyzabcde",
},
FormSubmit
contactForm: {
provider: "formsubmit",
formsubmitEmail: "[email protected]",
successRedirect: "/contact/?sent=1#contact-form",
},
Submit the form once after deploying, then click the confirmation link FormSubmit emails you.
Netlify Forms
contactForm: {
provider: "netlify",
formName: "contact",
},
Netlify detects the form when you deploy. Submissions appear under Forms in your Netlify dashboard.
Cloudflare
This is how the contact form on baselayerthemes.com works. The form posts to your own site, a Pages Function checks it, and a small Worker emails it to you. Everything runs on Cloudflare’s free plan, and no message passes through a third-party form service.
It has three parts:
- Turnstile, Cloudflare’s captcha, which most visitors never see. The function checks it on the server, so bots can’t skip it by posting to the form directly.
functions/api/contact.ts, the Pages Function. It checks the fields, a hidden spam field, and Turnstile, then hands the message to the mailer.mailer/, a tiny Worker that sends the email. Pages Functions can’t send email themselves, so the Worker holds the email binding. It has no public URL and can only email the one address you verify.
You need the site on Cloudflare Pages and your domain’s DNS on Cloudflare (for Email Routing).
1. Point the form at the function
In src/config/integrations.ts:
contactForm: {
provider: "cloudflare",
action: "/api/contact",
redirectPath: "/contact/",
turnstileSiteKeyPlaceholder: "your-turnstile-site-key",
},
The site key is public, so it’s safe in the repo. You’ll get it in step 2.
2. Create a Turnstile widget
In the Cloudflare dashboard, open Turnstile and add a widget. List your domain and your *.pages.dev address as hostnames, and choose Managed mode. Copy the site key into the config above and keep the secret key for step 5.
3. Turn on Email Routing
Open your domain in Cloudflare, then Email > Email Routing:
- Enable Email Routing and add the inbox where you want messages as a destination address. Cloudflare emails it a link to verify it.
- Add a routing rule that forwards your sending address (for example
[email protected]) to that inbox, so bounces and stray replies reach you too.
Email Routing takes over your domain’s incoming mail (MX records). If your domain already has a mailbox, such as Google Workspace, use a subdomain like mail.yourdomain.com for the sending address instead.
4. Add and deploy the mailer
Create mailer/wrangler.jsonc. Use your verified inbox for both destination_address and TO_ADDRESS:
{
"name": "my-site-mailer",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
"workers_dev": false,
"preview_urls": false,
"send_email": [{ "name": "EMAIL", "destination_address": "[email protected]" }],
"vars": {
"FROM_ADDRESS": "[email protected]",
"FROM_NAME": "My site",
"TO_ADDRESS": "[email protected]"
}
}
Then mailer/src/index.ts:
interface Env {
EMAIL: {
send(message: {
to: string;
from: { email: string; name: string };
replyTo?: string;
subject: string;
text: string;
}): Promise<unknown>;
};
FROM_ADDRESS: string;
FROM_NAME: string;
TO_ADDRESS: string;
}
export default {
async fetch(request: Request, env: Env) {
if (request.method !== "POST") return new Response(null, { status: 405 });
const { subject, text, replyTo } = (await request.json()) as {
subject: string;
text: string;
replyTo?: string;
};
try {
await env.EMAIL.send({
to: env.TO_ADDRESS,
from: { email: env.FROM_ADDRESS, name: env.FROM_NAME },
replyTo,
subject,
text,
});
return new Response(null, { status: 204 });
} catch (error) {
console.error("Contact email failed", error);
return new Response(null, { status: 502 });
}
},
};
Deploy it once, and again whenever you change it:
cd mailer
npx wrangler login
npx wrangler deploy
5. Add the Pages Function
Create functions/api/contact.ts in the project root (not in src/). Cloudflare Pages turns it into the /api/contact route when you deploy.
interface Env {
MAILER: { fetch(input: string, init?: RequestInit): Promise<Response> };
TURNSTILE_SECRET_KEY: string;
}
interface Context {
request: Request;
env: Env;
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const siteverify = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
export async function onRequestPost({ request, env }: Context) {
const form = await request.formData();
const field = (name: string, max: number) =>
String(form.get(name) ?? "").trim().slice(0, max);
const redirectTo = field("redirectTo", 200);
const back = (result: string) => {
const safe = redirectTo.startsWith("/") && !redirectTo.startsWith("//");
const url = new URL(safe ? redirectTo : "/contact/", request.url);
url.search = result;
url.hash = "contact-form";
return Response.redirect(url.toString(), 303);
};
// Bots fill the hidden "website" field. Say it worked, send nothing.
if (field("website", 200)) return back("sent=1");
const name = field("name", 100);
const email = field("email", 254);
const subject = field("subject", 200);
const message = field("message", 5000);
if (!name || !emailPattern.test(email) || !message) return back("error=invalid");
const check = new FormData();
check.append("secret", env.TURNSTILE_SECRET_KEY);
check.append("response", field("cf-turnstile-response", 2048));
const ip = request.headers.get("CF-Connecting-IP");
if (ip) check.append("remoteip", ip);
const verify = await fetch(siteverify, { method: "POST", body: check });
const { success } = (await verify.json()) as { success?: boolean };
if (!success) return back("error=captcha");
const sent = await env.MAILER.fetch("https://mailer/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
subject: (subject || `Message from ${name}`).replace(/[\r\n]+/g, " "),
replyTo: email,
text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
}),
});
return back(sent.ok ? "sent=1" : "error=email");
}
Reply-To is set to the visitor, so you can answer straight from your inbox.
6. Connect them in Cloudflare Pages
Open your Pages project, then Settings:
- Under Variables and Secrets, add
TURNSTILE_SECRET_KEYas a secret for Production. - Under Bindings, add a Service binding named
MAILERthat points to your mailer Worker.
Redeploy the site so the function picks up both. Submit the form once: you should get the email, and the form should show its thank-you message.
Testing locally
Turnstile has test keys that always pass: site key 1x00000000000000000000AA and secret 1x0000000000000000000000000000000AA. Put the test site key in the config temporarily, build, then run the mailer and the site side by side:
npm run build
cd mailer && npx wrangler dev --port 8788
# in a second terminal, from the project root:
npx wrangler pages dev dist \
--service MAILER=my-site-mailer \
--binding TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
Locally, emails are written to files under mailer/.wrangler/tmp/ instead of being sent.
No provider
With provider: null (or "mailto") the form opens a mailto: link to author.email in identity.ts. That’s fine while building, but browsers handle it inconsistently, so pick a real provider before launch.
Embed a hosted form
If you’d rather build the form in a form builder, embed it instead of using the theme’s form. Hosted forms handle fields, spam, storage, and notifications for you, and you edit them without touching code. The trade-off is that they load a script or frame from another company and won’t match your theme’s fonts and colors exactly.
| Service | Good for |
|---|---|
| Tally | A generous free plan and clean forms. Can embed with a transparent background so it sits on your page |
| Google Forms | Free, with responses in Google Sheets. Looks like Google |
| Typeform | Conversational forms that ask one question at a time |
| Jotform | Long forms, file uploads, payments, and conditional fields |
| HubSpot Forms | Sending contacts straight into the HubSpot CRM |
Newsletter signup forms from Kit, Mailchimp, or Buttondown embed the same way.
Add it to the contact page
Copy the embed code from the service’s Share or Embed panel. In src/pages/contact.astro, replace <ContactForm email={email} /> with it. The contact details column stays as it is.
A form that embeds as an iframe, such as Google Forms:
<div class="md:col-span-8">
<iframe
src="https://docs.google.com/forms/d/e/YOUR_FORM_ID/viewform?embedded=true"
title="Contact form"
loading="lazy"
class="h-[900px] w-full border-0"
></iframe>
</div>
Iframes don’t resize to fit their content, so adjust the height until the whole form shows without an inner scrollbar. Tally and Jotform offer a “dynamic height” option that does this for you.
A form that embeds with a script, such as HubSpot, Typeform, or Tally’s dynamic-height embed: paste the code as given, then add is:inline to every <script> tag.
<div class="md:col-span-8">
<!-- The service's markup, unchanged -->
<script is:inline src="https://example-provider.com/embed.js" async></script>
<script is:inline>
// Any setup code the service gives you
</script>
</div>
Without is:inline, Astro bundles the script with your site’s own code, and it can run before the service’s script has loaded, or not run at all.
Before launch
- Privacy policy. Name the service in
src/pages/privacy.md; it receives your visitors’ answers and may set its own cookies. - Thank-you message and notifications. Set these in the service’s settings. The theme’s
contactFormsettings inintegrations.tsonly apply to the theme’s own form. - Content Security Policy. The themes don’t restrict which scripts or frames can load. If you add a policy later, allow the service’s domains in
frame-srcandscript-src.
Labels and fields
contactForm: {
showSubject: true,
labels: {
nameLabel: "Name",
emailLabel: "Email",
subjectLabel: "Subject",
messageLabel: "Tell me about your project",
buttonLabel: "Send message",
},
},
Set showSubject: false to hide the subject field.
Spam
With Netlify Forms, the form includes a hidden honeypot field that people never see but bots tend to fill in, and Netlify drops those submissions. Formspree and FormSubmit run their own spam filtering on every submission. With Cloudflare, your function checks both a honeypot field and Turnstile.
After sending
Visitors return to /contact/?sent=1#contact-form, where the form shows a thank-you message. Change the destination with successRedirect or redirectPath.