How to Send Emails in Astro (2026 Guide)

Astro is a content-first framework, but it's fully capable of sending emails. With SSR or hybrid mode enabled, you get server endpoints that work like any Node.js backend. API keys stay server-side. And Astro's island architecture means your email logic runs on the server while your pages stay as static as possible.
Most Astro email tutorials stop at a basic contact form. This guide covers the full picture: React Email templates, Stripe webhooks, SaaS patterns, error handling, and deployment.
Enable SSR
Astro needs an adapter to run server-side code. Pick one for your deployment target:
npx astro add node # or vercel, netlify, cloudflare// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
// 'server' = all pages server-rendered
// 'hybrid' = static by default, opt-in to server per page
output: 'hybrid',
adapter: node({ mode: 'standalone' }),
});hybrid mode is usually best: most pages stay static (fast), but you can opt individual pages and endpoints into server-side rendering when they need it.
Pick a Provider
Every code example below lets you switch between three providers.
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one SDK. Native Stripe integration and built-in retries.
- Resend is developer-friendly. Clean API, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise option. Feature-rich, high volume. Bigger API surface.
Install
npm install sequenzynpm install resendnpm install @sendgrid/mailAdd your API key to .env:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereInitialize the Client
import Sequenzy from "sequenzy";
// Reads SEQUENZY_API_KEY from env automatically
export const sequenzy = new Sequenzy();import { Resend } from "resend";
export const resend = new Resend(import.meta.env.RESEND_API_KEY);import sgMail from "@sendgrid/mail";
sgMail.setApiKey(import.meta.env.SENDGRID_API_KEY);
export { sgMail };In Astro, env vars without the PUBLIC_ prefix are server-only. import.meta.env.RESEND_API_KEY is never exposed to the browser.
Send Your First Email
Astro pages can handle POST requests directly in the frontmatter. This is the simplest approach: the form submits to the same page, the server processes it, and the page re-renders with the result.
---
// src/pages/contact.astro
import { sequenzy } from "../lib/email";
let success = false;
let error = "";
if (Astro.request.method === "POST") {
const data = await Astro.request.formData();
const name = data.get("name") as string;
const email = data.get("email") as string;
const message = data.get("message") as string;
if (!name || !email || !message) {
error = "All fields are required";
} else {
try {
await sequenzy.transactional.send({
to: "you@yourcompany.com",
subject: `Contact from ${name}`,
body: `
<h2>New Contact Form</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
success = true;
} catch {
error = "Failed to send message. Try again.";
}
}
}
---
<html>
<body>
<h1>Contact Us</h1>
{success ? (
<p style="color: green">Message sent! We'll get back to you soon.</p>
) : (
<form method="POST">
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="Your message" rows="4" required></textarea>
<button type="submit">Send Message</button>
{error && <p style="color: red">{error}</p>}
</form>
)}
</body>
</html>For hybrid mode, add the server directive at the top:
---
export const prerender = false; // This page runs on the server
// ... rest of frontmatter
---Server Endpoints (API Routes)
For JSON APIs, webhooks, and programmatic email sending, use server endpoints:
import type { APIRoute } from "astro";
import { sequenzy } from "../../lib/email";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${import.meta.env.INTERNAL_API_KEY}`) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
const { to, subject, body } = await request.json();
if (!to || !subject || !body) {
return new Response(
JSON.stringify({ error: "Missing required fields: to, subject, body" }),
{ status: 400 },
);
}
try {
const result = await sequenzy.transactional.send({ to, subject, body });
return new Response(JSON.stringify({ success: true, id: result.id }));
} catch {
return new Response(JSON.stringify({ error: "Failed to send" }), { status: 500 });
}
};import type { APIRoute } from "astro";
import { resend } from "../../lib/email";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${import.meta.env.INTERNAL_API_KEY}`) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
const { to, subject, html } = await request.json();
if (!to || !subject || !html) {
return new Response(
JSON.stringify({ error: "Missing required fields: to, subject, html" }),
{ status: 400 },
);
}
const { data, error } = await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to,
subject,
html,
});
if (error) {
return new Response(JSON.stringify({ error: error.message }), { status: 500 });
}
return new Response(JSON.stringify({ success: true, id: data?.id }));
};import type { APIRoute } from "astro";
import { sgMail } from "../../lib/email";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${import.meta.env.INTERNAL_API_KEY}`) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
const { to, subject, html } = await request.json();
if (!to || !subject || !html) {
return new Response(
JSON.stringify({ error: "Missing required fields: to, subject, html" }),
{ status: 400 },
);
}
try {
await sgMail.send({ to, from: "noreply@yourdomain.com", subject, html });
return new Response(JSON.stringify({ success: true }));
} catch {
return new Response(JSON.stringify({ error: "Failed to send" }), { status: 500 });
}
};export const prerender = false tells Astro this endpoint needs server-side rendering (required in hybrid mode).
React Email Templates
Inline HTML strings don't scale. React Email builds email templates as React components. Since Astro already supports React via @astrojs/react, this integrates cleanly.
npx astro add react
npm install @react-email/components react-email// src/emails/layout.tsx
import {
Html, Head, Body, Container, Text, Hr,
} from "@react-email/components";
interface EmailLayoutProps {
children: React.ReactNode;
}
export function EmailLayout({ children }: EmailLayoutProps) {
return (
<Html>
<Head />
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
<Container style={{
backgroundColor: "#ffffff",
padding: "40px",
borderRadius: "8px",
margin: "40px auto",
maxWidth: "560px",
}}>
{children}
<Hr style={{ borderColor: "#e6ebf1", margin: "32px 0" }} />
<Text style={{ color: "#8898aa", fontSize: "12px" }}>
YourApp Inc. · 123 Main St · San Francisco, CA
</Text>
</Container>
</Body>
</Html>
);
}// src/emails/welcome.tsx
import { Text, Button, Heading } from "@react-email/components";
import { EmailLayout } from "./layout";
interface WelcomeEmailProps {
name: string;
loginUrl: string;
}
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<EmailLayout>
<Heading as="h1" style={{ fontSize: "24px", color: "#1a1a1a" }}>
Welcome, {name}!
</Heading>
<Text style={{ fontSize: "16px", color: "#4a4a4a", lineHeight: "26px" }}>
Your account is ready. Here's what to do next:
</Text>
<Text style={{ fontSize: "16px", color: "#4a4a4a", lineHeight: "26px" }}>
1. Set up your first project{"\n"}
2. Invite your team{"\n"}
3. Connect your integrations
</Text>
<Button
href={loginUrl}
style={{
backgroundColor: "#5046e5",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "6px",
textDecoration: "none",
display: "inline-block",
marginTop: "16px",
}}
>
Go to Dashboard
</Button>
</EmailLayout>
);
}Render and send:
import { render } from "@react-email/components";
import { sequenzy } from "./email";
import { WelcomeEmail } from "../emails/welcome";
export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
return sequenzy.transactional.send({
to,
subject: `Welcome to YourApp, ${name}!`,
body: html,
});
}import { render } from "@react-email/components";
import { resend } from "./email";
import { WelcomeEmail } from "../emails/welcome";
export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
return resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to,
subject: `Welcome to YourApp, ${name}!`,
html,
});
}import { render } from "@react-email/components";
import { sgMail } from "./email";
import { WelcomeEmail } from "../emails/welcome";
export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
return sgMail.send({
to,
from: "noreply@yourdomain.com",
subject: `Welcome to YourApp, ${name}!`,
html,
});
}Common SaaS Patterns
Password Reset
import type { APIRoute } from "astro";
import { render } from "@react-email/components";
import { sequenzy } from "../../lib/email";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
export const prerender = false;
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
export const POST: APIRoute = async ({ request, url }) => {
const { email } = await request.json();
if (!email) {
return new Response(JSON.stringify({ error: "Email is required" }), { status: 400 });
}
// Always return success to prevent email enumeration
const user = await db.user.findByEmail(email);
if (!user) {
return new Response(JSON.stringify({ success: true }));
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000);
await db.resetToken.create({ userId: user.id, token, expires });
const resetUrl = `${url.origin}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
await sequenzy.transactional.send({
to: email,
subject: "Reset your password",
body: html,
});
return new Response(JSON.stringify({ success: true }));
};import type { APIRoute } from "astro";
import { render } from "@react-email/components";
import { resend } from "../../lib/email";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
export const prerender = false;
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
export const POST: APIRoute = async ({ request, url }) => {
const { email } = await request.json();
if (!email) {
return new Response(JSON.stringify({ error: "Email is required" }), { status: 400 });
}
const user = await db.user.findByEmail(email);
if (!user) {
return new Response(JSON.stringify({ success: true }));
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000);
await db.resetToken.create({ userId: user.id, token, expires });
const resetUrl = `${url.origin}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to: email,
subject: "Reset your password",
html,
});
return new Response(JSON.stringify({ success: true }));
};import type { APIRoute } from "astro";
import { render } from "@react-email/components";
import { sgMail } from "../../lib/email";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
export const prerender = false;
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
export const POST: APIRoute = async ({ request, url }) => {
const { email } = await request.json();
if (!email) {
return new Response(JSON.stringify({ error: "Email is required" }), { status: 400 });
}
const user = await db.user.findByEmail(email);
if (!user) {
return new Response(JSON.stringify({ success: true }));
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000);
await db.resetToken.create({ userId: user.id, token, expires });
const resetUrl = `${url.origin}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
try {
await sgMail.send({
to: email,
from: "noreply@yourdomain.com",
subject: "Reset your password",
html,
});
} catch {
console.error("Failed to send password reset email");
}
return new Response(JSON.stringify({ success: true }));
};Stripe Webhook
Stripe sends the raw body for signature verification. Astro's request.text() gives you exactly that:
import type { APIRoute } from "astro";
import Stripe from "stripe";
import { sequenzy } from "../../lib/email";
export const prerender = false;
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
export const POST: APIRoute = async ({ request }) => {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
import.meta.env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_details?.email;
if (email) {
await sequenzy.transactional.send({
to: email,
subject: "Payment received — thank you!",
body: `
<h2>Payment Receipt</h2>
<p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>
<p><a href="https://yourapp.com/billing">View your billing</a></p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await sequenzy.transactional.send({
to: invoice.customer_email,
subject: "Payment failed — action required",
body: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p><a href="https://yourapp.com/billing">Update Payment Method</a></p>
`,
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(
subscription.customer as string,
) as Stripe.Customer;
if (customer.email) {
await sequenzy.transactional.send({
to: customer.email,
subject: "Your subscription has been cancelled",
body: `
<h2>We're sorry to see you go</h2>
<p>Your subscription has been cancelled.
You still have access until the end of your billing period.</p>
`,
});
}
break;
}
}
return new Response(JSON.stringify({ received: true }));
};import type { APIRoute } from "astro";
import Stripe from "stripe";
import { resend } from "../../lib/email";
export const prerender = false;
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
export const POST: APIRoute = async ({ request }) => {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
import.meta.env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_details?.email;
if (email) {
await resend.emails.send({
from: "YourApp <billing@yourdomain.com>",
to: email,
subject: "Payment received — thank you!",
html: `
<h2>Payment Receipt</h2>
<p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await resend.emails.send({
from: "YourApp <billing@yourdomain.com>",
to: invoice.customer_email,
subject: "Payment failed — action required",
html: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p><a href="https://yourapp.com/billing">Update Payment Method</a></p>
`,
});
}
break;
}
}
return new Response(JSON.stringify({ received: true }));
};import type { APIRoute } from "astro";
import Stripe from "stripe";
import { sgMail } from "../../lib/email";
export const prerender = false;
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
export const POST: APIRoute = async ({ request }) => {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
import.meta.env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_details?.email;
if (email) {
await sgMail.send({
to: email,
from: "billing@yourdomain.com",
subject: "Payment received — thank you!",
html: `
<h2>Payment Receipt</h2>
<p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await sgMail.send({
to: invoice.customer_email,
from: "billing@yourdomain.com",
subject: "Payment failed — action required",
html: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p><a href="https://yourapp.com/billing">Update Payment Method</a></p>
`,
});
}
break;
}
}
return new Response(JSON.stringify({ received: true }));
};Astro uses web-standard Request, so request.text() gives you the raw body without any middleware configuration.
Error Handling
import Sequenzy from "sequenzy";
import { sequenzy } from "./email";
interface SendResult {
success: boolean;
error?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
body: string,
): Promise<SendResult> {
try {
await sequenzy.transactional.send({ to, subject, body });
return { success: true };
} catch (error) {
if (error instanceof Sequenzy.RateLimitError) {
console.warn(`Rate limited. Retry after: ${error.retryAfter}s`);
return { success: false, error: "Too many emails. Try again later." };
}
if (error instanceof Sequenzy.AuthenticationError) {
console.error("Invalid SEQUENZY_API_KEY");
return { success: false, error: "Email service configuration error" };
}
if (error instanceof Sequenzy.ValidationError) {
return { success: false, error: "Invalid email parameters" };
}
console.error("Email error:", error);
return { success: false, error: "Failed to send email" };
}
}import { resend } from "./email";
interface SendResult {
success: boolean;
error?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
html: string,
): Promise<SendResult> {
const { error } = await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to,
subject,
html,
});
if (error) {
console.error(`Resend error [${error.name}]: ${error.message}`);
switch (error.name) {
case "rate_limit_exceeded":
return { success: false, error: "Too many emails. Try again later." };
case "validation_error":
return { success: false, error: "Invalid email parameters" };
case "invalid_api_key":
return { success: false, error: "Email service configuration error" };
default:
return { success: false, error: "Failed to send email" };
}
}
return { success: true };
}import { sgMail } from "./email";
interface SendResult {
success: boolean;
error?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
html: string,
): Promise<SendResult> {
try {
await sgMail.send({ to, from: "noreply@yourdomain.com", subject, html });
return { success: true };
} catch (error: unknown) {
const sgError = error as { code?: number };
if (sgError.code === 429) {
return { success: false, error: "Too many emails. Try again later." };
}
if (sgError.code === 401) {
return { success: false, error: "Email service configuration error" };
}
console.error("SendGrid error:", error);
return { success: false, error: "Failed to send email" };
}
}Interactive Forms with Islands
Astro's island architecture lets you add client-side interactivity for form submissions without a full page reload. Use a React, Svelte, or Vue component:
// src/components/ContactForm.tsx (React island)
import { useState } from "react";
export function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("sending");
const form = new FormData(e.currentTarget);
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.get("name"),
email: form.get("email"),
message: form.get("message"),
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to send");
}
setStatus("sent");
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Failed to send");
}
}
if (status === "sent") {
return <p style={{ color: "green" }}>Message sent! We'll get back to you soon.</p>;
}
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="Your message" rows={4} required />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending..." : "Send Message"}
</button>
{status === "error" && <p style={{ color: "red" }}>{error}</p>}
</form>
);
}Use it in an Astro page with client:load:
---
// src/pages/contact.astro
import { ContactForm } from "../components/ContactForm";
---
<html>
<body>
<h1>Contact Us</h1>
<ContactForm client:load />
</body>
</html>The client:load directive hydrates the component on page load. The form submits via fetch to your API endpoint. Choose between this (SPA-like UX) or the plain HTML form approach (no JavaScript required), depending on your needs.
Production Checklist
1. Verify Your Sending Domain
| Record | Type | Purpose |
|---|---|---|
| SPF | TXT | Authorizes servers to send |
| DKIM | TXT | Cryptographic signature |
| DMARC | TXT | Policy for failed checks |
See our email authentication guide for step-by-step instructions.
2. Server-Only Environment Variables
In Astro, env vars without the PUBLIC_ prefix are server-only. import.meta.env.SEQUENZY_API_KEY is only available in server code (endpoints, frontmatter). If you prefix with PUBLIC_, it's exposed to the client.
3. Choose Your Output Mode
output: 'server': Every page is server-rendered. Simpler, but you lose static site benefits.output: 'hybrid': Pages are static by default. Addexport const prerender = falseto opt individual pages/endpoints into SSR. Best of both worlds.
4. Input Validation
import { z } from "zod";
const contactSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
message: z.string().min(10).max(5000),
});
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify({ error: parsed.error.issues[0].message }),
{ status: 400 },
);
}
// Safe to use parsed.data
};5. Middleware for Rate Limiting
Astro supports middleware for cross-cutting concerns:
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
export const onRequest = defineMiddleware(({ request, clientAddress }, next) => {
const url = new URL(request.url);
if (url.pathname.startsWith("/api/") && request.method === "POST") {
const ip = clientAddress || "unknown";
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (entry && now < entry.resetAt && entry.count >= 5) {
return new Response(
JSON.stringify({ error: "Too many requests" }),
{ status: 429 },
);
}
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 });
} else {
entry.count++;
}
}
return next();
});Beyond Transactional
Once you have transactional emails working, you'll likely want welcome emails and onboarding sequences. If you're unsure where the line is, see transactional vs. marketing email.
import type { APIRoute } from "astro";
import { sequenzy } from "../../lib/email";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const { email, name } = await request.json();
if (!email) {
return new Response(JSON.stringify({ error: "Email is required" }), { status: 400 });
}
await sequenzy.subscribers.add({
email,
attributes: { name },
tags: ["signed-up"],
});
return new Response(JSON.stringify({ success: true }));
};
// Sequenzy triggers welcome sequences automatically
// when the "signed-up" tag is applied.import type { APIRoute } from "astro";
import { resend } from "../../lib/email";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const { email, name } = await request.json();
if (!email) {
return new Response(JSON.stringify({ error: "Email is required" }), { status: 400 });
}
const { error } = await resend.contacts.create({
audienceId: import.meta.env.RESEND_AUDIENCE_ID,
email,
firstName: name,
});
if (error) {
return new Response(JSON.stringify({ error: error.message }), { status: 500 });
}
return new Response(JSON.stringify({ success: true }));
};import type { APIRoute } from "astro";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const { email, name } = await request.json();
if (!email) {
return new Response(JSON.stringify({ error: "Email is required" }), { status: 400 });
}
const response = await fetch("https://api.sendgrid.com/v3/marketing/contacts", {
method: "PUT",
headers: {
Authorization: `Bearer ${import.meta.env.SENDGRID_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
list_ids: [import.meta.env.SENDGRID_LIST_ID],
contacts: [{ email, first_name: name }],
}),
});
if (!response.ok) {
return new Response(JSON.stringify({ error: "Failed to subscribe" }), { status: 500 });
}
return new Response(JSON.stringify({ success: true }));
};Sequenzy handles transactional sends, marketing campaigns, automated sequences, and subscriber management from one SDK. Native Stripe integration tags subscribers automatically when they purchase, cancel, or churn.
FAQ
Do I need SSR enabled to send emails?
Yes. Astro's default static mode pre-renders pages at build time with no server runtime. You need output: 'server' or output: 'hybrid' to run server-side code. The hybrid mode is ideal: your content pages stay static (fast), and only your API endpoints and form-handling pages run on the server.
What's the difference between server and hybrid output modes?
server renders every page on the server per-request. hybrid pre-renders all pages statically by default; you opt individual pages or endpoints into server rendering with export const prerender = false. For email, you only need SSR on the pages/endpoints that handle forms and API calls.
Can I send emails from static Astro pages?
Not directly. Static pages have no server runtime. You can either:
- Enable hybrid mode and mark your email endpoint as
prerender = false - Use a third-party form service (Formspree, Basin) that handles sending externally
- Call an external API (like a separate Express server) from client-side JavaScript
How do I use React Email if Astro already supports React?
React Email components run server-side to produce HTML strings. They don't use Astro's React integration (@astrojs/react), which is for interactive client-side islands. You import React Email components in your server endpoints or src/lib/ files, call render(), and get an HTML string to pass to your email provider.
Can I use Astro Actions instead of API endpoints?
Yes. Astro Actions (introduced in Astro 4.x) provide type-safe server functions. They work like a built-in RPC layer:
// src/actions/index.ts
import { defineAction, z } from "astro:actions";
import { sequenzy } from "../lib/email";
export const server = {
sendContact: defineAction({
input: z.object({
name: z.string(),
email: z.string().email(),
message: z.string().min(10),
}),
handler: async ({ name, email, message }) => {
await sequenzy.transactional.send({
to: "you@yourcompany.com",
subject: `Contact from ${name}`,
body: `<p>${message}</p>`,
});
return { success: true };
},
}),
};Which adapter should I use?
@astrojs/node: Self-hosted Node.js server (Railway, Render, VPS)@astrojs/vercel: Vercel serverless functions@astrojs/netlify: Netlify Functions@astrojs/cloudflare: Cloudflare Pages (note: some Node.js APIs may need compatibility flags)
Your email code works the same across all adapters.
How do I handle email in Astro middleware?
Astro middleware runs before every request. You can use it for rate limiting (shown above) or for injecting shared context (like an authenticated user), but avoid sending emails directly from middleware. Keep email logic in dedicated endpoints and page handlers.
Can I preview React Email templates locally?
Yes. Run npx react-email dev to start the React Email preview server at localhost:3000. It auto-detects templates in your project and renders them with hot reload.
Wrapping Up
hybridmode gives you static pages plus server-side email endpoints- Server endpoints (
src/pages/api/*.ts) handle JSON APIs and webhooks - Page frontmatter handles HTML form submissions directly
- React islands add client-side interactivity for SPA-like form UX
import.meta.envkeeps API keys server-only (noPUBLIC_prefix)- React Email works server-side for maintainable templates
- Astro middleware handles rate limiting across all endpoints
Pick your provider, enable SSR, and start sending. If you're also running a standalone Node.js backend, see our Node.js email guide for server-specific patterns.
Frequently Asked Questions
Can I send emails from Astro without enabling SSR?
No. Static Astro sites generate HTML at build time and can't run server-side code at request time. You need SSR mode (output: 'server' or output: 'hybrid') to create API endpoints that handle email sending. Alternatively, use an external backend API.
Which Astro adapter should I use for email sending?
Choose based on your hosting: @astrojs/node for Node.js hosting, @astrojs/vercel for Vercel, @astrojs/cloudflare for Cloudflare Pages, or @astrojs/netlify for Netlify. All support server-side API endpoints where you can send emails.
How do I create an email-sending API endpoint in Astro?
Create a .ts file in src/pages/api/ (e.g., src/pages/api/send-email.ts). Export an async POST function that handles the request, calls your email SDK, and returns a Response. Astro's file-based routing makes this straightforward.
Can I use React Email templates in Astro?
Yes. Since Astro supports React components, you can build email templates with React Email and render them server-side in your API endpoints. Install @react-email/components and call render() in your endpoint handler.
How do I handle form submissions that send emails in Astro?
For static pages, submit the form to your API endpoint using fetch() in a client-side script. For SSR pages, you can handle the form submission server-side in the page's POST handler. Both approaches keep your API keys on the server.
Should I use Astro middleware for email-related logic?
Use middleware for cross-cutting concerns like rate limiting and authentication that apply to all email endpoints. Don't put email-sending logic in middleware—keep that in individual API endpoint handlers for clarity and easier testing.
How do I handle environment variables for email keys in Astro?
Use import.meta.env.YOUR_KEY in server-side code. Prefix public variables with PUBLIC_ (never do this for API keys). Store keys in .env locally and in your hosting provider's environment settings for production. Astro loads .env files automatically.
Can I send emails from Astro Islands (client components)?
Not directly—Islands run in the browser. Instead, have your Island component call a server-side API endpoint via fetch(), and send the email from that endpoint. This keeps API keys secure while letting you use interactive React/Vue/Svelte components for forms.
How do I add CAPTCHA to email forms in Astro?
Add a CAPTCHA widget (reCAPTCHA, Turnstile) to your form component, then verify the token server-side in your API endpoint before sending the email. Cloudflare Turnstile is the lightest option and works well with Astro's Cloudflare adapter.
What's the best way to handle email errors in Astro endpoints?
Return appropriate HTTP status codes (200 for success, 400 for validation errors, 500 for send failures) with JSON error messages. On the client side, check the response status and display user-friendly error messages. Log detailed errors server-side for debugging.