| name | bootstrap-astro-ses |
| description | Bootstrap Astro site with AWS SES form handling |
Bootstrap Astro with AWS SES Form Handling
Set up an Astro project with SSR and AWS SES email integration for contact forms.
Prerequisites Check
Before proceeding, verify:
- Astro project exists with SSR adapter (
@astrojs/vercel, @astrojs/node, etc.)
- If no Astro project exists, create one first with
pnpm create astro@latest
Phase 1: Install Dependencies
pnpm add @aws-sdk/client-ses zod
Phase 2: Environment Configuration
Create .env file
# AWS Credentials
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
AWS_REGION=us-east-2
# Email Configuration
FROM_EMAIL=noreply@yourdomain.com
CONTACT_EMAIL_TO=your-email@yourdomain.com
# Development Only
DISABLE_EMAIL=true
Create .env.example (for version control)
# AWS Credentials
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=us-east-2
# Email Configuration
FROM_EMAIL=noreply@yourdomain.com
CONTACT_EMAIL_TO=
# Development Only
DISABLE_EMAIL=true
Update .gitignore
Add:
.env
.env.local
!.env.example
Phase 3: Create Email Utility
File: src/lib/email.ts
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
import type { SendEmailCommandInput } from '@aws-sdk/client-ses';
const getEnvVar = (key: string): string | undefined => {
return import.meta.env?.[key] || process.env[key];
};
export async function createSESClient(): Promise<SESClient | null> {
try {
if (getEnvVar('DISABLE_EMAIL') === 'true') {
console.log('Email sending is disabled via DISABLE_EMAIL environment variable');
return null;
}
const accessKeyId = getEnvVar('AWS_ACCESS_KEY_ID');
const secretAccessKey = getEnvVar('AWS_SECRET_ACCESS_KEY');
const region = getEnvVar('AWS_REGION') || 'us-east-2';
if (!accessKeyId || !secretAccessKey) {
console.warn('AWS credentials not found. Email sending will be disabled.');
return null;
}
return new SESClient({
region,
credentials: { accessKeyId, secretAccessKey },
});
} catch (error: any) {
console.error('Failed to create SES client:', error.message);
return null;
}
}
export async function sendEmail(
sesClient: SESClient,
options: {
to: string | string[];
from: string;
subject: string;
html: string;
text: string;
replyTo?: string;
}
): Promise<boolean> {
try {
const params: SendEmailCommandInput = {
Source: options.from,
Destination: {
ToAddresses: Array.isArray(options.to) ? options.to : [options.to],
},
Message: {
Subject: { Data: options.subject, Charset: 'UTF-8' },
Body: {
Html: { Data: options.html, Charset: 'UTF-8' },
Text: { Data: options.text, Charset: 'UTF-8' },
},
},
ReplyToAddresses: options.replyTo ? [options.replyTo] : undefined,
};
await sesClient.send(new SendEmailCommand(params));
return true;
} catch (error: any) {
console.error('Failed to send email:', error.message);
return false;
}
}
Phase 4: Create Validation Schema
File: src/shared/schema.ts
import { z } from 'zod';
export const contactFormSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Invalid email address'),
company: z.string().optional(),
message: z.string().min(1, 'Message is required'),
});
export type ContactFormData = z.infer<typeof contactFormSchema>;
Phase 5: Create API Endpoint
File: src/pages/api/contact.ts
import type { APIRoute } from 'astro';
import { z } from 'zod';
import { createSESClient, sendEmail } from '@/lib/email';
import { contactFormSchema } from '@/shared/schema';
import type { SESClient } from '@aws-sdk/client-ses';
export const prerender = false;
let sesClient: SESClient | null = null;
const getEnvVar = (key: string): string | undefined => {
return import.meta.env?.[key] || process.env[key];
};
function formatEmailHtml(data: {
firstName: string;
lastName: string;
email: string;
company?: string;
message: string;
}): string {
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #333;">New Contact Form Submission</h1>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px; color: #666;"><strong>Name:</strong></td>
<td style="padding: 8px;">${data.firstName} ${data.lastName}</td>
</tr>
<tr>
<td style="padding: 8px; color: #666;"><strong>Email:</strong></td>
<td style="padding: 8px;"><a href="mailto:${data.email}">${data.email}</a></td>
</tr>
<tr>
<td style="padding: 8px; color: #666;"><strong>Company:</strong></td>
<td style="padding: 8px;">${data.company || 'Not provided'}</td>
</tr>
</table>
<h2 style="color: #333;">Message</h2>
<p style="color: #444; line-height: 1.6;">${data.message}</p>
</body>
</html>
`;
}
function formatEmailText(data: {
firstName: string;
lastName: string;
email: string;
company?: string;
message: string;
}): string {
return `
New Contact Form Submission
Name: ${data.firstName} ${data.lastName}
Email: ${data.email}
Company: ${data.company || 'Not provided'}
Message:
${data.message}
`.trim();
}
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const validatedData = contactFormSchema.parse(body);
if (!sesClient) {
sesClient = await createSESClient();
}
let emailSent = false;
if (sesClient) {
const toEmail = getEnvVar('CONTACT_EMAIL_TO') || 'your-email@example.com';
const fromEmail = getEnvVar('FROM_EMAIL') || 'noreply@example.com';
emailSent = await sendEmail(sesClient, {
to: toEmail,
from: fromEmail,
subject: `New Contact: ${validatedData.firstName} ${validatedData.lastName}`,
html: formatEmailHtml(validatedData),
text: formatEmailText(validatedData),
replyTo: validatedData.email,
});
}
return new Response(
JSON.stringify({ success: true, emailSent }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
if (error instanceof z.ZodError) {
return new Response(
JSON.stringify({ success: false, error: 'Validation failed', details: error.errors }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
console.error('API error:', error);
return new Response(
JSON.stringify({ success: false, error: 'Internal server error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
Phase 6: Create React Form Component
File: src/components/ContactForm.tsx
import { useState } from 'react';
import { z } from 'zod';
const formSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Invalid email address'),
company: z.string().optional(),
message: z.string().min(1, 'Message is required'),
});
type FormData = z.infer<typeof formSchema>;
export default function ContactForm() {
const [formData, setFormData] = useState<FormData>({
firstName: '',
lastName: '',
email: '',
company: '',
message: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: '' }));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setErrors({});
const result = formSchema.safeParse(formData);
if (!result.success) {
const fieldErrors: Record<string, string> = {};
result.error.errors.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
setIsSubmitting(false);
return;
}
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await response.json();
if (data.success) {
setSubmitStatus('success');
setFormData({ firstName: '', lastName: '', email: '', company: '', message: '' });
} else {
setSubmitStatus('error');
if (data.details) {
const fieldErrors: Record<string, string> = {};
data.details.forEach((err: any) => {
if (err.path[0]) {
fieldErrors[err.path[0]] = err.message;
}
});
setErrors(fieldErrors);
}
}
} catch {
setSubmitStatus('error');
} finally {
setIsSubmitting(false);
}
};
if (submitStatus === 'success') {
return (
<div className="success-message">
<h3>Thank you!</h3>
<p>We've received your message and will get back to you soon.</p>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="firstName">First Name *</label>
<input type="text" id="firstName" name="firstName" value={formData.firstName} onChange={handleChange} />
{errors.firstName && <span className="error">{errors.firstName}</span>}
</div>
<div>
<label htmlFor="lastName">Last Name *</label>
<input type="text" id="lastName" name="lastName" value={formData.lastName} onChange={handleChange} />
{errors.lastName && <span className="error">{errors.lastName}</span>}
</div>
<div>
<label htmlFor="email">Email *</label>
<input type="email" id="email" name="email" value={formData.email} onChange={handleChange} />
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label htmlFor="company">Company</label>
<input type="text" id="company" name="company" value={formData.company} onChange={handleChange} />
</div>
<div>
<label htmlFor="message">Message *</label>
<textarea id="message" name="message" value={formData.message} onChange={handleChange} rows={5} />
{errors.message && <span className="error">{errors.message}</span>}
</div>
{submitStatus === 'error' && <div className="error-message">Something went wrong. Please try again.</div>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
Phase 7: Create Contact Page
File: src/pages/contact.astro
---
import Layout from '@/layouts/Layout.astro';
import ContactForm from '@/components/ContactForm';
---
<Layout title="Contact Us">
<main>
<h1>Contact Us</h1>
<ContactForm client:load />
</main>
</Layout>
Phase 8: Update TypeScript Config
File: tsconfig.json
Ensure path aliases are configured:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@shared/*": ["src/shared/*"]
}
}
}
Verification Checklist
After setup, verify:
File Structure
src/
├── lib/
│ └── email.ts # SES client and email utilities
├── shared/
│ └── schema.ts # Zod validation schemas
├── components/
│ └── ContactForm.tsx # React form component
└── pages/
├── contact.astro # Contact page
└── api/
└── contact.ts # API endpoint
AWS SES Setup Notes
- Create IAM User with
AmazonSESFullAccess policy
- Verify sender email in AWS SES Console → Verified Identities
- Request production access to send to unverified emails (SES sandbox mode limits recipients)
Development Tips
- Set
DISABLE_EMAIL=true in development to skip actual email sending
- Email content is logged to console when disabled
- Test form validation before enabling real email sending