React Hook Form + Zod on the front, Supabase Edge Function + Resend on the back. The first form in your training that actually does something.
This is the line. In Project 1, the contact form was a visual. Here, it accepts a real submission, validates it, sends a real email to admissions, and shows the parent a real success message.
By the end of this page:
Parents fill in name, email, child's grade, and message.
Invalid input is caught and shown inline.
A POST request hits your edge function.
The function emails the admissions inbox via Resend.
The parent sees "Thanks — we'll be in touch."
Plan for ~2-3 hours. Take it slow. This is the most concentrated technical work in Project 2.
Verify a sending domain. For now, you can use Resend's free onresend.dev sender — Aurora School <onboarding@resend.dev>. For production, add your real domain later (the Resend tool guide covers DNS setup).
This creates supabase/functions/contact-inquiry/index.ts. Open it. Replace the contents:
import { Resend } from "npm:resend@4.0.0";const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY")!;const ADMISSIONS_EMAIL = Deno.env.get("ADMISSIONS_EMAIL")!;const FROM_EMAIL = Deno.env.get("FROM_EMAIL")!;const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "content-type",};interface ContactBody { name?: string; email?: string; grade?: string; message?: string;}Deno.serve(async (req) => { // CORS preflight if (req.method === "OPTIONS") { return new Response("ok", { headers: corsHeaders }); } if (req.method !== "POST") { return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { ...corsHeaders, "content-type": "application/json" }, }); } let body: ContactBody; try { body = await req.json(); } catch { return new Response(JSON.stringify({ error: "Invalid JSON" }), { status: 400, headers: { ...corsHeaders, "content-type": "application/json" }, }); } // Server-side validation (don't trust the client) const { name, email, grade, message } = body; const errors: string[] = []; if (!name || name.trim().length < 2) errors.push("Name is required"); if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.push("Valid email required"); if (!grade) errors.push("Grade is required"); if (!message || message.trim().length < 10) errors.push("Message must be at least 10 characters"); if (errors.length > 0) { return new Response(JSON.stringify({ error: errors.join(". ") }), { status: 400, headers: { ...corsHeaders, "content-type": "application/json" }, }); } // Send email const resend = new Resend(RESEND_API_KEY); const { error } = await resend.emails.send({ from: FROM_EMAIL, to: ADMISSIONS_EMAIL, replyTo: email, subject: `Admissions inquiry — ${name} (Grade ${grade})`, text: [ `New admission inquiry from ${name} <${email}>`, ``, `Grade: ${grade}`, ``, `Message:`, message, ].join("\n"), }); if (error) { console.error("Resend error:", error); return new Response(JSON.stringify({ error: "Failed to send email. Please try again or call us directly." }), { status: 500, headers: { ...corsHeaders, "content-type": "application/json" }, }); } return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { ...corsHeaders, "content-type": "application/json" }, });});
A few things to notice in this code:
No secrets in the code. API keys and the admissions email come from Deno.env.get(...). We'll set those as Supabase secrets shortly.
CORS headers on every response. Browsers block cross-origin POSTs unless the function explicitly allows it.
Server-side validation. Even though we validate on the front-end too, we also validate here. A malicious user can bypass the front-end; they cannot bypass this.
replyTo is set to the parent's email. When admissions hits Reply in their inbox, they reply to the parent not to themselves.
pnpm supabase secrets set RESEND_API_KEY=re_your_actual_keypnpm supabase secrets set ADMISSIONS_EMAIL=admissions@aurora-school.inpnpm supabase secrets set FROM_EMAIL="Aurora School <onboarding@resend.dev>"
(Replace with your actual values. The FROM_EMAIL uses onresend.dev for now — replace with your real verified domain once you add one.)
Open src/app/admissions/page.tsx. Find the placeholder section with [Inquiry form goes here]. Replace it:
import { InquiryForm } from '@/components/InquiryForm'// ...inside the JSX, replace the placeholder div with:<div className="max-w-md mx-auto"> <InquiryForm /></div>
Save. Visit http://localhost:3000/admissions and scroll to the bottom. The form is there.
Try it:
Submit with empty fields. You see inline red errors.
Type a bad email like notanemail. Inline error.
Fill everything correctly. "Sending..." appears on the button. After ~2 seconds: "Inquiry received."
Check your admissions inbox. The email arrives.
If any step fails, open the browser DevTools network tab, click the failed contact-inquiry request, and look at the response. Common issues:
401 Unauthorized → forgot to send the anon key in the authorization header.
500 → check the Supabase function logs (pnpm supabase functions logs contact-inquiry).
"Failed to fetch" → check the function URL in .env.local.
git add .git commit -m "working contact form with Resend edge function"git push
After Vercel rebuilds, open https://aurora-school.vercel.app/admissions, scroll to the form, and submit. The email should arrive in admissions. Your site is no longer a brochure.