Build the Les Caramboles Enrollment Form
Build a bilingual enrollment form with PDF generation and email delivery.
Introduction
30 Second Summary
Every school needs a way for parents to enroll their children online. Most start with a third-party form builder, but eventually outgrow it when they need custom validation, branded emails, and full control over their data.
In this project, you will build a bilingual enrollment form for Les Caramboles, an international school in Cameroon. Parents fill in their details, submit the form, and receive a confirmation email with a PDF summary of their enrollment.
What You'll Build
You will have a working enrollment page where a parent fills out their family details, clicks submit, and finds a confirmation email with a branded PDF receipt in their inbox.
By the end of this project, you'll have:
- A bilingual landing page with the school's branding, contact details, and a multi-section enrollment form where parents enter family situation, guardian information, and child details with French and English labels.
- Server-side validation that catches empty fields, rejects emails from disposable providers, prevents duplicate submissions, and silently traps bots with a hidden honeypot field.
- An automatic confirmation email with a PDF summary attached, previewable in your browser through Nodemailer's test service so no real emails are sent. After submitting, parents also see a success screen with a WhatsApp community invite.
- Secret Mission: Add reCAPTCHA v3 invisible bot detection that scores every submission and silently blocks automated spam.
Are there any prerequisites?
This is Part 4 of the Les Caramboles series, building on the Next.js App Router, Mongoose, and server actions you learned in Projects 1 through 3.
You need Node.js and MongoDB installed and running locally before starting.
Before We Start
Before we dive in, take a moment to think about what you're building and why the school needs it.
Putting this into words now helps you stay focused through the steps ahead.
Set Up the Enrollment App
The enrollment form lives in its own standalone Next.js app, separate from the main school website. This way the school can later host it on its own subdomain (like enrollment.mywebsite.com) and deploy it independently.
To get started you need a fresh Next.js project that connects to the same lescaramboles MongoDB database your other apps already use. In this step, you will scaffold the enrollment app, install its dependencies, and define the Mongoose model that stores parent and child enrollment data.
In this step, get ready to:
- Scaffold a new Next.js app running on port 3001.
- Install project dependencies and configure the environment.
- Create the Enrollment Mongoose model with all form fields and a unique email index.
Scaffold the enrollment app
The main Les Caramboles site runs on port 3000. You will run the enrollment app on port 3001 so both can be open at the same time during development.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the new Next.js project by running:
npx create-next-app@latest les-caramboles-enrollment
What will you see?
The CLI will ask you:
Would you like to use the recommended Next.js defaults?
- Select Yes, use recommended defaults. This gives you TypeScript, ESLint, Tailwind CSS, and the App Router out of the box.
- Once the scaffolding finishes, move into the new project folder by running:
cd les-caramboles-enrollment
- Confirm the project files were created by running:
ls
You should see files like package.json, next.config.ts, tsconfig.json, and an app folder listed in the output.
Command not found?
Make sure Node.js is installed and available in your terminal. You should have it from the earlier projects in this series.
help me troubleshoot why npx create-next-app is not working
The main school site uses port 3000. You need to configure this project to use port 3001 instead so both apps can run at the same time.
- Open les-caramboles-enrollment in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the les-caramboles-enrollment folder on your Desktop, then click Open.
- Open package.json from the Explorer sidebar.
- Find the "dev" script line and update it to include -p 3001 so it looks like this:
"dev": "next dev --turbopack -p 3001"
What does -p 3001 do?
The -p flag tells the Next.js dev server which port to listen on. Setting it to 3001 means you can keep the main school site running on 3000 while developing the enrollment app side by side.
- Save package.json.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick).
- Start the dev server by running:
npm run dev
Open http://localhost:3001 in your browser. You should see the default Next.js starter page.
Seeing a port conflict?
If something else is already using port 3001, stop that process first. Make sure you saved package.json after editing the dev script.
help me fix a port conflict when starting my Next.js dev server on port 3001
Install dependencies and configure the environment
The enrollment app needs several packages. Mongoose connects to MongoDB. Zod validates form data. Nodemailer sends emails. pdf-lib generates PDFs. next-intl handles bilingual translations.
- Stop the dev server with Ctrl+C.
- Install all five packages by running:
npm install mongoose zod nodemailer pdf-lib next-intl
You should see the packages added to your package.json dependencies once the install finishes.
Install failed?
Check that you are inside the les-caramboles-enrollment folder. Running pwd in the terminal should show a path ending in les-caramboles-enrollment.
help me debug why npm install failed in my Next.js project
Now create the environment file that tells the app where to find MongoDB and stores the WhatsApp invite link.
- Create a new file called .env.local in the root of your les-caramboles-enrollment folder (same level as package.json).
- Add the following environment variables:
MONGODB_URI=mongodb://127.0.0.1:27017/lescaramboles
NEXT_PUBLIC_WHATSAPP_LINK=https://chat.whatsapp.com/your-invite-code-here
Why lescaramboles and not a new database?
All the school apps share one MongoDB database called lescaramboles. The main site, enrollment form, and future LMS each define their own models but read and write to the same database. This keeps student and parent data in one place.
Next you need the database connection utility. This is the same caching pattern you used in earlier projects.
- Create a lib folder in the root of your project.
- Inside lib, create a file called dbConnect.ts and add this code:
import mongoose from 'mongoose';
const MONGODB_URI = process.env.MONGODB_URI!;
if (!MONGODB_URI) {
throw new Error('Please define the MONGODB_URI environment variable inside .env.local');
}
let cached = (global as any).mongoose;
if (!cached) {
cached = (global as any).mongoose = { conn: null, promise: null };
}
async function dbConnect() {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
cached.promise = mongoose.connect(MONGODB_URI).then((mongoose) => mongoose);
}
cached.conn = await cached.promise;
return cached.conn;
}
export default dbConnect;
What does this code do?
This utility caches the Mongoose connection so it is reused across hot reloads during development. Without caching, every code change would open a new database connection until MongoDB runs out.
The (global as any).mongoose trick stores the connection on Node's global object, which survives Next.js hot reloads.
- Save lib/dbConnect.ts.
Now set up next-intl so the app can display French and English labels. You need a request config file, the plugin in your Next.js config, and two message files.
- Create a messages folder in the root of your project.
- Inside messages, create fr.json with placeholder content:
{
"title": "Preinscription"
}
- Create en.json in the same messages folder:
{
"title": "Pre-enrollment"
}
- Create an i18n folder in the root of your project.
- Inside i18n, create request.ts with this code:
import {getRequestConfig} from 'next-intl/server';
export default getRequestConfig(async () => {
// Default to French since the school is in Cameroon
const locale = 'fr';
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default
};
});
- Open next.config.ts and replace its contents with:
import {NextConfig} from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
const nextConfig: NextConfig = {};
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);
What does the next-intl plugin do?
The createNextIntlPlugin() call links your i18n/request.ts file to next-intl. It automatically looks for i18n/request.ts in the project root. Every time a page renders, next-intl calls your config function to load the right locale and messages.
- Save all files.
- Restart the dev server to pick up the new config by running:
npm run dev
Refresh http://localhost:3001 in your browser. The default Next.js page should still load without errors. If you see any build errors in the terminal, double-check your next.config.ts and i18n/request.ts files.
Create the Enrollment model
The Enrollment model defines every field from the school's existing JotForm: family situation, parent details, child details, and remarks. It also includes a unique index on the mother's email to prevent duplicate submissions.
- Create a models folder in the root of your project.
- Inside models, create a file called Enrollment.ts and add the import and the first half of the schema:
import mongoose, { Schema, model, models } from 'mongoose';
const EnrollmentSchema = new Schema({
// Family context
familySituation: { type: String, required: true },
childLivingWith: { type: String, required: true },
// Mother or legal guardian
mother: {
firstName: { type: String, required: true },
lastName: { type: String, required: true },
email: { type: String, required: true },
address: { type: String },
country: { type: String },
city: { type: String },
postalCode: { type: String },
phone: { type: String, required: true },
},
What does this code do?
This defines a Mongoose schema that maps directly to the school's enrollment form. The mother object groups all the guardian's contact details. Fields marked required: true will cause a database-level error if they are missing.
- Add the rest of the schema directly below the mother block in models/Enrollment.ts:
// Father details (optional)
father: {
firstName: { type: String },
lastName: { type: String },
email: { type: String },
address: { type: String },
country: { type: String },
city: { type: String },
postalCode: { type: String },
phone: { type: String },
},
// Child information
child: {
firstName: { type: String, required: true },
lastName: { type: String, required: true },
dateOfBirth: { type: Date, required: true },
requestedClass: { type: String, required: true },
gender: { type: String, required: true },
currentSchool: { type: String },
specialNeeds: { type: String },
professionalSupport: { type: String },
},
remarks: { type: String },
honeypot: { type: String },
createdAt: { type: Date, default: Date.now },
});
// Prevent duplicate enrollments from the same email
EnrollmentSchema.index({ 'mother.email': 1 }, { unique: true });
export const Enrollment = models.Enrollment ?? model('Enrollment', EnrollmentSchema);
What does the unique index do?
The EnrollmentSchema.index({ 'mother.email': 1 }, { unique: true }) line tells MongoDB to reject any second document that tries to use the same mother email. This is a database-level safety net against duplicate enrollments.
The honeypot field is for anti-spam. You will wire it up in a later step.
- Save models/Enrollment.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved models/Enrollment.ts, lib/dbConnect.ts, and .env.local.
ⓧ I'd like to double check the full code
Scroll up and confirm each file matches. Here is a checklist of the files you should have created in this step:
- .env.local with MONGODB_URI and NEXT_PUBLIC_WHATSAPP_LINK.
- lib/dbConnect.ts with the cached Mongoose connection utility.
- models/Enrollment.ts with the full schema (family, mother, father, child, remarks, honeypot, createdAt) and the unique index on mother.email.
- messages/fr.json and messages/en.json with placeholder translations.
- i18n/request.ts with the getRequestConfig function.
- next.config.ts updated with the next-intl plugin.
Now let's verify the Enrollment model and its unique index are registered in MongoDB.
Before you run this, what do you think getIndexes() will return? Think about what index you just defined on the schema.
- Open a separate terminal tab (keep the dev server running) and verify the index by running:
mongosh lescaramboles --eval 'db.enrollments.getIndexes()'
You should see two indexes in the output: the default _id index and your unique index on mother.email.
What if the index does not appear?
The unique index is created when Mongoose first connects and syncs the model. If you see only the _id index, make sure your dev server is running (it triggers the connection). Refresh http://localhost:3001 once, then re-run the mongosh command. The unique index should now appear.
mongosh command not found?
Make sure MongoDB is installed and running locally. You should have it from the earlier projects in this series. If mongosh is not in your PATH, try using the full path to the mongosh binary.
help me connect to my local MongoDB to verify my Enrollment model index
Your enrollment app is scaffolded, connected to MongoDB, and ready for the form. Next up, you will build the hero section and the enrollment form that parents actually fill out.
Build the Hero Section and Enrollment Form
Your enrollment app is scaffolded and the Mongoose model is ready to store enrollment data. Now it is time to build what parents will actually see and interact with.
Right now the app shows the default Next.js starter page. Parents need a professional landing page that builds trust before they commit to a long enrollment form. In this step, you will replace the starter page with a branded hero section and build the complete enrollment form that saves submissions straight to MongoDB.
In this step, get ready to:
- Build the hero section with the school's purple branding and bilingual SEO metadata.
- Create a multi-section enrollment form capturing family, parent, and child details.
- Wire the form to a server action that saves submissions to the database.
Build the landing page with the hero section
The landing page is the first thing parents see. It needs to show the school name, contact info, and a tagline that matches the school's brand.
- Replace the contents of app/page.tsx with the hero section by pasting this code:
import type { Metadata } from 'next';
// Bilingual SEO metadata for search engines
export const metadata: Metadata = {
title: 'Preinscription | Ecole Maternelle Internationale Les Caramboles',
description:
'Formulaire de preinscription en ligne / Online pre-enrollment form - Ecole Maternelle Internationale Les Caramboles, Yaounde.',
};
export default function HomePage() {
return (
<main className="min-h-screen bg-gray-50">
{/* Hero section with school branding */}
<section className="bg-purple-700 text-white py-12 px-4 text-center">
<h1 className="text-3xl font-bold mb-2">
Ecole Maternelle Internationale Les Caramboles
</h1>
<p className="text-lg mb-1">Yaounde, Carrefour Regie</p>
<p className="text-sm">Tel: 657 078 888 / 652 333 103</p>
<p className="text-sm">lescaramboles2021@gmail.com</p>
<p className="mt-4 text-purple-200 italic">
Grandir ensemble, apprendre avec joie
</p>
</section>
</main>
);
}
What does this code do?
The metadata export sets the page title and description that appear in browser tabs and search results. Next.js automatically injects these into the HTML <head> tag.
The hero section uses Tailwind CSS classes like bg-purple-700 to match the school's purple brand. The school name, Yaounde address, phone numbers, and email are all visible so parents know they are in the right place.
- Save app/page.tsx.
- Return to your browser and open http://localhost:3001. You should see a purple banner with the school name, address, phone numbers, and email. The default Next.js starter page is gone.
Still seeing the default starter page?
- Make sure you saved the file. Unsaved changes will not trigger a hot reload.
- Confirm you replaced the entire contents of app/page.tsx, not a different file.
- Check your terminal for errors. If the dev server stopped, restart it with npm run dev.
Why isn't my hero section showing up after saving page.tsx?
Build the enrollment form component
The enrollment form is a client component because it needs interactive state for all the input fields. You will build it section by section, starting with the component skeleton and adding each form group.
- Create a new folder called components inside the root of your les-caramboles-enrollment project.
- Create a new file called EnrollmentForm.tsx inside the components folder. Set up the form skeleton with all the state fields by pasting this code:
'use client';
import { useState } from 'react';
export default function EnrollmentForm() {
const [submitted, setSubmitted] = useState(false);
// Template for parent fields (mother and father share the same shape)
const emptyParent = { firstName: '', lastName: '', email: '', address: '', country: '', city: '', postalCode: '', phone: '' };
const [formData, setFormData] = useState({
familySituation: '', childLivingWith: '',
mother: { ...emptyParent }, father: { ...emptyParent },
child: { firstName: '', lastName: '', dateOfBirth: '', requestedClass: '', gender: '', currentSchool: '', specialNeeds: '', professionalSupport: '' },
remarks: '', honeypot: '',
});
// Update a nested field like mother.firstName or child.dateOfBirth
const updateField = (section: string, field: string, value: string) => {
setFormData((prev: any) => ({
...prev, [section]: { ...prev[section], [field]: value },
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
};
return (
<form onSubmit={handleSubmit} className="space-y-8">
</form>
);
}
What does this code do?
The 'use client' directive marks this as a client component so it can use React state and event handlers.
The emptyParent template avoids repeating the same field list for both the mother and father objects. The updateField helper handles nested state updates. Calling updateField('mother', 'firstName', 'Marie') updates just formData.mother.firstName without overwriting the other mother fields.
- Save components/EnrollmentForm.tsx.
- Now update app/page.tsx to import and render the form. Add this import at the very top of the file, below the existing Metadata import:
import EnrollmentForm from '@/components/EnrollmentForm';
- In the same file, find the closing </section> tag of the hero section. Add this form section directly below it, before the closing </main> tag:
{/* Enrollment form section */}
<section className="max-w-2xl mx-auto py-8 px-4">
<h2 className="text-2xl font-bold text-purple-800 mb-6">
Fiche de Preinscription / Enrollment Form
</h2>
<EnrollmentForm />
</section>
- Save app/page.tsx and refresh your browser.
- You should see the heading "Fiche de Preinscription / Enrollment Form" below the hero. The form area is empty for now because the <form> tag has no content yet.
Now you will fill in the form sections one group at a time. Each chunk adds a visible section of fields.
- In components/EnrollmentForm.tsx, find the empty <form> tag. Add the family situation dropdowns inside it by pasting this code between the opening and closing <form> tags:
{/* Family situation */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-purple-700">
Situation familiale / Family Situation
</h3>
<select value={formData.familySituation}
onChange={e => setFormData({ ...formData, familySituation: e.target.value })}
className="w-full border p-2 rounded">
<option value="">-- Selectionnez / Select --</option>
<option value="married">Marie(e) / Married</option>
<option value="single">Celibataire / Single</option>
<option value="divorced">Divorce(e) / Divorced</option>
<option value="widowed">Veuf/Veuve / Widowed</option>
</select>
<select value={formData.childLivingWith}
onChange={e => setFormData({ ...formData, childLivingWith: e.target.value })}
className="w-full border p-2 rounded">
<option value="">-- L'enfant vit avec / Child lives with --</option>
<option value="both">Les deux parents / Both parents</option>
<option value="mother">La mere / Mother</option>
<option value="father">Le pere / Father</option>
<option value="other">Autre / Other</option>
</select>
</div>
What does this code do?
These two dropdowns capture the family's situation (married, single, divorced, widowed) and who the child lives with. Each <select> is a controlled component. Its value is bound to a field in formData, and the onChange handler updates that field when the user picks an option.
- Save components/EnrollmentForm.tsx and refresh your browser.
- You should see two dropdown menus below the form heading. Each one shows its bilingual placeholder text.
- Below the closing </div> of the family situation section, add the mother/guardian fields:
{/* Mother / Guardian */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-purple-700">
Mere / Responsable legal | Mother / Guardian
</h3>
<div className="grid grid-cols-2 gap-4">
<input type="text" placeholder="Prenom / First name *" value={formData.mother.firstName} onChange={e => updateField('mother', 'firstName', e.target.value)} className="border p-2 rounded" />
<input type="text" placeholder="Nom / Last name *" value={formData.mother.lastName} onChange={e => updateField('mother', 'lastName', e.target.value)} className="border p-2 rounded" />
</div>
<input type="email" placeholder="Email *" value={formData.mother.email} onChange={e => updateField('mother', 'email', e.target.value)} className="w-full border p-2 rounded" />
<input type="text" placeholder="Adresse / Address" value={formData.mother.address} onChange={e => updateField('mother', 'address', e.target.value)} className="w-full border p-2 rounded" />
<div className="grid grid-cols-2 gap-4">
<input type="text" placeholder="Pays / Country" value={formData.mother.country} onChange={e => updateField('mother', 'country', e.target.value)} className="border p-2 rounded" />
<input type="text" placeholder="Ville / City" value={formData.mother.city} onChange={e => updateField('mother', 'city', e.target.value)} className="border p-2 rounded" />
</div>
<input type="text" placeholder="Code postal / Postal code" value={formData.mother.postalCode} onChange={e => updateField('mother', 'postalCode', e.target.value)} className="w-full border p-2 rounded" />
<input type="tel" placeholder="Telephone / Phone *" value={formData.mother.phone} onChange={e => updateField('mother', 'phone', e.target.value)} className="w-full border p-2 rounded" />
</div>
What does this code do?
Each input uses the updateField helper you created earlier. Calling updateField('mother', 'email', ...) updates just the email inside formData.mother without touching the other fields.
Fields marked with * in the placeholder are required. The grid layout places first name and last name side by side. The email field uses type="email" so browsers can offer basic format hints.
- Save and refresh your browser. You should see eight input fields for the mother/guardian section, with first name and last name side by side in a grid.
- Below the mother section's closing </div>, add the father section. It follows the same pattern but targets the father key in state:
{/* Father */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-purple-700">Pere / Father</h3>
<div className="grid grid-cols-2 gap-4">
<input type="text" placeholder="Prenom / First name" value={formData.father.firstName} onChange={e => updateField('father', 'firstName', e.target.value)} className="border p-2 rounded" />
<input type="text" placeholder="Nom / Last name" value={formData.father.lastName} onChange={e => updateField('father', 'lastName', e.target.value)} className="border p-2 rounded" />
</div>
<input type="email" placeholder="Email" value={formData.father.email} onChange={e => updateField('father', 'email', e.target.value)} className="w-full border p-2 rounded" />
<input type="text" placeholder="Adresse / Address" value={formData.father.address} onChange={e => updateField('father', 'address', e.target.value)} className="w-full border p-2 rounded" />
<div className="grid grid-cols-2 gap-4">
<input type="text" placeholder="Pays / Country" value={formData.father.country} onChange={e => updateField('father', 'country', e.target.value)} className="border p-2 rounded" />
<input type="text" placeholder="Ville / City" value={formData.father.city} onChange={e => updateField('father', 'city', e.target.value)} className="border p-2 rounded" />
</div>
<input type="text" placeholder="Code postal / Postal code" value={formData.father.postalCode} onChange={e => updateField('father', 'postalCode', e.target.value)} className="w-full border p-2 rounded" />
<input type="tel" placeholder="Telephone / Phone" value={formData.father.phone} onChange={e => updateField('father', 'phone', e.target.value)} className="w-full border p-2 rounded" />
</div>
- Save and refresh. The father section appears below the mother fields with the same grid layout. Notice none of the father fields have * in the placeholder because they are all optional.
- Below the father section's closing </div>, add the child information section, the remarks field, the hidden honeypot, and the submit button:
{/* Child information */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-purple-700">Enfant / Child</h3>
<div className="grid grid-cols-2 gap-4">
<input type="text" placeholder="Prenom / First name *" value={formData.child.firstName} onChange={e => updateField('child', 'firstName', e.target.value)} className="border p-2 rounded" />
<input type="text" placeholder="Nom / Last name *" value={formData.child.lastName} onChange={e => updateField('child', 'lastName', e.target.value)} className="border p-2 rounded" />
</div>
<input type="date" value={formData.child.dateOfBirth} onChange={e => updateField('child', 'dateOfBirth', e.target.value)} className="w-full border p-2 rounded" />
<select value={formData.child.requestedClass} onChange={e => updateField('child', 'requestedClass', e.target.value)} className="w-full border p-2 rounded">
<option value="">-- Classe demandee / Requested class * --</option>
<option value="TPS">TPS (Toute Petite Section)</option>
<option value="PS">PS (Petite Section)</option>
<option value="MS">MS (Moyenne Section)</option>
<option value="GS">GS (Grande Section)</option>
</select>
<select value={formData.child.gender} onChange={e => updateField('child', 'gender', e.target.value)} className="w-full border p-2 rounded">
<option value="">-- Sexe / Gender * --</option>
<option value="male">Garcon / Male</option>
<option value="female">Fille / Female</option>
</select>
<input type="text" placeholder="Ecole actuelle / Current school" value={formData.child.currentSchool} onChange={e => updateField('child', 'currentSchool', e.target.value)} className="w-full border p-2 rounded" />
<textarea placeholder="Besoins specifiques / Special needs" value={formData.child.specialNeeds} onChange={e => updateField('child', 'specialNeeds', e.target.value)} className="w-full border p-2 rounded" rows={2} />
<textarea placeholder="Suivi professionnel / Professional support" value={formData.child.professionalSupport} onChange={e => updateField('child', 'professionalSupport', e.target.value)} className="w-full border p-2 rounded" rows={2} />
</div>
{/* Additional remarks */}
<textarea placeholder="Remarques / Additional remarks" value={formData.remarks} onChange={e => setFormData({ ...formData, remarks: e.target.value })} className="w-full border p-2 rounded" rows={3} />
{/* Honeypot field - invisible to humans, catches bots */}
<input type="text" value={formData.honeypot} onChange={e => setFormData({ ...formData, honeypot: e.target.value })} className="hidden" tabIndex={-1} autoComplete="off" aria-hidden="true" />
{/* Submit button */}
<button type="submit" className="w-full bg-purple-700 text-white py-3 rounded-lg font-semibold hover:bg-purple-800">
Soumettre la preinscription / Submit Enrollment
</button>
What is the honeypot field?
The honeypot is a hidden text input that real humans never see or fill in. Automated bots, however, detect the input in the HTML and fill it with text. Your server action can check this field later to silently reject bot submissions.
The className="hidden" hides it visually, tabIndex={-1} prevents keyboard navigation to it, and aria-hidden="true" hides it from screen readers.
- Save components/EnrollmentForm.tsx and refresh your browser.
- You should see the complete form with child fields (including a date picker and class dropdown), a remarks text area, and a purple submit button at the bottom. The honeypot field is not visible.
Form not rendering at all?
- Check your terminal for a syntax error. A missing closing </div> or </select> tag will break the entire component.
- Make sure every section you added is inside the <form> tag, not outside it.
- Confirm that 'use client' is still the very first line in the file.
My enrollment form component isn't rendering. Help me debug.
✔️ Awesome, I've got everything!
Great. Double check you have saved both components/EnrollmentForm.tsx and app/page.tsx.
ⓧ I'd like to double check the full code
Your components/EnrollmentForm.tsx is a large file built across several chunks. Scroll up and confirm each section is present inside the <form> tag, in this order:
- Family situation section with two dropdown selects.
- Mother/Guardian section with eight input fields.
- Father section with eight input fields.
- Child section with text inputs, date picker, two select dropdowns, and two textareas.
- A remarks textarea.
- A hidden honeypot input with className="hidden".
- A purple submit button.
Create the server action and wire the form
The form looks complete, but clicking the submit button does nothing. You need a server action that receives the form data and saves it to MongoDB.
- Create a new folder called actions inside the app directory.
- Create a new file called enroll.ts inside app/actions. Build the server action by pasting this code:
'use server';
import dbConnect from '@/lib/dbConnect';
import { Enrollment } from '@/models/Enrollment';
export async function submitEnrollment(formData: Record<string, any>) {
// Connect to the shared lescaramboles database
await dbConnect();
// Save the enrollment data directly (no validation yet)
await Enrollment.create(formData);
return { success: true };
}
What does this server action do?
The 'use server' directive marks this function as a server action. Next.js creates a POST endpoint behind the scenes so the client component can call it securely.
The action connects to MongoDB using your dbConnect utility, then passes the raw form data straight to Enrollment.create(). There is no validation yet. Anything the user submits gets saved.
- Save app/actions/enroll.ts.
Now wire the form component to call this server action on submit and show a success screen when the save completes.
- Switch back to components/EnrollmentForm.tsx.
- Find this import line at the top:
import { useState } from 'react';
- Add this import directly below it:
import { submitEnrollment } from '@/app/actions/enroll';
- Find the handleSubmit function:
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); };
- Replace it with this version that calls the server action and sets the submitted state:
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Call the server action with the form data
const result = await submitEnrollment(formData);
if (result.success) {
setSubmitted(true);
}
};
- Now add the success screen. Find the line that says return ( (the one right before the <form> tag). Add this block directly above it:
// Show success screen after a successful submission
if (submitted) {
const whatsappLink = process.env.NEXT_PUBLIC_WHATSAPP_LINK || 'https://chat.whatsapp.com/your-invite-code-here';
return (
<div className="text-center py-12">
<h3 className="text-2xl font-bold text-purple-700 mb-4">
Merci! / Thank you!
</h3>
<p className="text-gray-600 mb-6">
Votre demande de preinscription a ete envoyee avec succes.
</p>
<a href={whatsappLink} target="_blank" rel="noopener noreferrer"
className="inline-block bg-green-500 text-white px-6 py-3 rounded-lg font-semibold hover:bg-green-600">
Rejoindre notre communaute WhatsApp
</a>
</div>
);
}
What does the success screen do?
When submitted is true, the component returns the thank-you message instead of the form. The if (submitted) block sits above the main return, so it intercepts the render before the form JSX runs.
The WhatsApp button reads its URL from the NEXT_PUBLIC_WHATSAPP_LINK environment variable in your .env.local file. Next.js inlines any NEXT_PUBLIC_ variable into client components at build time.
- Save components/EnrollmentForm.tsx.
- Refresh your browser and fill in the form with valid test data. Use a real-looking name and a gmail.com email address for the mother's email field.
- Click Soumettre la preinscription / Submit Enrollment. The form should swap to a success screen showing the thank-you message and a green WhatsApp button.
- Confirm the data reached MongoDB by running this command in a new terminal:
mongosh lescaramboles --eval 'db.enrollments.find().pretty()'
- You should see your enrollment document with all the fields you filled in.
Form submits but no success screen?
- Check the terminal where your dev server is running. If you see a connection error, make sure MongoDB is running locally.
- Confirm the import path is exactly @/app/actions/enroll and that the file is at app/actions/enroll.ts.
- Make sure you imported submitEnrollment (not a default import). The function uses a named export.
My form is not calling the server action. Help me debug.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files.
ⓧ I'd like to double check the server action
Here is the complete app/actions/enroll.ts file:
'use server';
import dbConnect from '@/lib/dbConnect';
import { Enrollment } from '@/models/Enrollment';
export async function submitEnrollment(formData: Record<string, any>) {
await dbConnect();
await Enrollment.create(formData);
return { success: true };
}
The form saves a valid enrollment. But what happens if someone submits garbage?
Before you try this, what do you think will happen if you submit the form with mostly empty fields and a nonsense email like asdf@fakemail123.com?
- Clear the test data from your previous submission by running:
mongosh lescaramboles --eval 'db.enrollments.deleteMany({})'
- Refresh the enrollment page so the form reappears.
- Fill in just the mother's email with asdf@fakemail123.com and leave every other field blank.
- Click Soumettre la preinscription / Submit Enrollment.
The form shows the success screen. It accepted the garbage without complaint. Verify this by running:
mongosh lescaramboles --eval 'db.enrollments.find().pretty()'
The database now contains an enrollment document full of empty strings and a fake email. There is nothing stopping parents from submitting junk, duplicate entries, or emails from disposable providers. This is the problem you will fix next.
Why did garbage data save?
The server action passes form data directly to Enrollment.create() with no validation in between. Mongoose's required: true only rejects missing fields (not empty strings). An empty string passes the required check.
- Clean up the test data by running:
mongosh lescaramboles --eval 'db.enrollments.deleteMany({})'
Your enrollment form saves to the database, but it accepts absolutely anything. Next up, you will add strict validation with Zod to reject empty fields, filter out fake email domains, and prevent duplicate submissions.
Add Zod Validation with Email Domain Filtering
Your enrollment form saves data to MongoDB and shows a success screen. But at the end of the last step, you saw the gap: submitting empty fields, nonsense emails, or the same data twice all saved without complaint.
Without validation, the database fills with garbage and the school wastes time reviewing fake submissions. Zod is a TypeScript validation library that lets you define exactly what valid data looks like. In this step, you will create a Zod schema that validates every field, filters email domains to block disposable addresses, and prevents duplicate submissions.
In this step, get ready to:
- Create a Zod validation schema with a custom email domain allowlist.
- Update the server action to validate data and reject duplicates before saving.
- Test the validation flow with invalid inputs and confirm errors display on the form.
Create the Zod validation schema
Every field in the enrollment form needs rules. Some fields are required. The mother's email must come from a recognized provider like Gmail or Yahoo, not a throwaway address. Zod lets you define all these rules in one schema and validate incoming data against them.
- Create a new file called lib/enrollmentSchema.ts in your les-caramboles-enrollment project.
- Paste the following code to set up the import, the allowed email domain list, and the start of the schema:
import { z } from 'zod';
// Only accept emails from major, permanent providers
const ALLOWED_EMAIL_DOMAINS = [
'gmail.com',
'yahoo.com',
'yahoo.fr',
'outlook.com',
'hotmail.com',
'hotmail.fr',
'icloud.com',
'protonmail.com',
'proton.me',
'aol.com',
'live.com',
'orange.fr',
'laposte.net',
];
export const enrollmentSchema = z.object({
familySituation: z.string().min(1, 'Ce champ est requis / This field is required'),
childLivingWith: z.string().min(1, 'Ce champ est requis / This field is required'),
What does this code do?
ALLOWED_EMAIL_DOMAINS is the allowlist of email providers the school will accept. It covers French-speaking markets (orange.fr, laposte.net, yahoo.fr) since the school is in Yaounde, Cameroon.
Each field uses z.string().min(1, '...') to require at least one character. The error messages are bilingual (French first, then English) so they work for all parents.
- Directly below the childLivingWith line, paste the mother object with its email domain validation:
mother: z.object({
firstName: z.string().min(1, 'Prenom requis / First name required'),
lastName: z.string().min(1, 'Nom requis / Last name required'),
email: z
.string()
.email('Adresse e-mail invalide / Invalid email address')
.refine(
(email) => {
const domain = email.split('@')[1]?.toLowerCase();
return ALLOWED_EMAIL_DOMAINS.includes(domain);
},
{
message:
'Veuillez utiliser une adresse e-mail d\'un fournisseur reconnu (Gmail, Yahoo, Outlook, iCloud, ProtonMail, etc.) / Please use an email address from a recognized provider (Gmail, Yahoo, Outlook, iCloud, ProtonMail, etc.)',
}
),
address: z.string().optional(),
country: z.string().optional(),
city: z.string().optional(),
postalCode: z.string().optional(),
phone: z.string().min(1, 'Telephone requis / Phone required'),
}),
What does .refine() do?
The .email() check only confirms the string looks like an email. The .refine() adds a custom check on top: it extracts the domain after the @ and looks it up in the allowlist.
If the domain is not in the list, Zod rejects the email with a bilingual error message. This blocks disposable email providers like tempmail.com while accepting addresses parents actually use.
- Below the mother object's closing }), paste the father, child, and remaining fields to complete the schema:
father: z
.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().optional(),
address: z.string().optional(),
country: z.string().optional(),
city: z.string().optional(),
postalCode: z.string().optional(),
phone: z.string().optional(),
})
.optional(),
child: z.object({
firstName: z.string().min(1, 'Prenom requis / First name required'),
lastName: z.string().min(1, 'Nom requis / Last name required'),
dateOfBirth: z.string().min(1, 'Date de naissance requise / Date of birth required'),
requestedClass: z.string().min(1, 'Classe requise / Class required'),
gender: z.string().min(1, 'Ce champ est requis / This field is required'),
currentSchool: z.string().optional(),
specialNeeds: z.string().optional(),
professionalSupport: z.string().optional(),
}),
remarks: z.string().optional(),
honeypot: z.string().optional(),
});
export type EnrollmentData = z.infer<typeof enrollmentSchema>;
What does this code do?
The entire father object is .optional() because not every enrollment includes a father. The child object requires the fields the school needs for class assignment: date of birth, requested class (TPS/PS/MS/GS), and gender.
The honeypot field is optional because legitimate submissions leave it empty. The exported EnrollmentData type lets you use the schema's shape in other files.
- Save lib/enrollmentSchema.ts.
- Check your file in VS Code. Any red underlines mean a syntax issue, like a missing comma or unmatched bracket. The file should have no errors once all three sections are pasted in order.
Seeing red underlines in the schema file?
Make sure each section is pasted in order with no gaps or overlaps. The z.object({ at the top must close with }); at the bottom after honeypot. Check that every nested }), has a matching opening brace.
help me fix syntax errors in my enrollmentSchema.ts file
✔️ Awesome, I've got everything!
Great. Double check you have saved lib/enrollmentSchema.ts with no errors.
ⓧ I'd like to double check the full code
Your lib/enrollmentSchema.ts should have these sections in order:
- The import { z } from 'zod' statement and the ALLOWED_EMAIL_DOMAINS array with 13 domains.
- The enrollmentSchema export opening with familySituation and childLivingWith.
- The mother object with the .refine() on email.
- The father object (optional), the child object, remarks, honeypot, and the closing });.
- The export type EnrollmentData line at the bottom.
Wire the server action to validate before saving
The server action currently saves everything straight to the database. Now it needs to run the data through the Zod schema first, check for duplicate emails, and return errors to the form when something is wrong.
- Open app/actions/enroll.ts.
- Select all the existing code and delete it.
- Paste the new server action that validates before saving:
'use server';
import dbConnect from '@/lib/dbConnect';
import { Enrollment } from '@/models/Enrollment';
import { enrollmentSchema } from '@/lib/enrollmentSchema';
export async function submitEnrollment(formData: Record<string, any>) {
// Honeypot check: if the hidden field has any value, a bot filled it
if (formData.honeypot) {
// Return fake success so the bot thinks it worked
return { success: true };
}
// Validate with Zod
const result = enrollmentSchema.safeParse(formData);
if (!result.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of result.error.issues) {
const path = issue.path.join('.');
fieldErrors[path] = issue.message;
}
return { success: false, errors: fieldErrors };
}
What does this code do?
The honeypot check runs first. If the hidden field has any text, a bot filled it. The action returns fake success so the bot thinks it worked, but nothing is saved.
safeParse() runs the form data through every rule in your Zod schema without throwing an error. If validation fails, it loops through each issue, builds a map of field paths (like mother.email) to error messages, and returns them to the form.
- Directly below the code you just pasted, add the duplicate check and save logic:
const validated = result.data;
await dbConnect();
// Check for duplicate email
const existing = await Enrollment.findOne({ 'mother.email': validated.mother.email });
if (existing) {
return {
success: false,
errors: {
'mother.email':
'Cette adresse e-mail a deja ete utilisee pour une preinscription. / This email address has already been used for a pre-enrollment.',
},
};
}
// Save to MongoDB
await Enrollment.create(validated);
return { success: true };
}
What does this code do?
result.data contains the validated and typed form data. The action queries MongoDB for an existing enrollment with the same mother's email. If one exists, it returns a duplicate error instead of saving.
Notice the action uses validated (the Zod-checked data) for both the duplicate query and the Enrollment.create() call. Raw form data never reaches the database.
- Save app/actions/enroll.ts.
- Check the terminal where npm run dev is running. Next.js recompiles on save. You should see no errors in the output.
Seeing a compile error in the terminal?
If Next.js reports an import error for enrollmentSchema, confirm that lib/enrollmentSchema.ts is saved and that the export name matches: export const enrollmentSchema. Also check that the import path in enroll.ts is @/lib/enrollmentSchema (not ./lib/enrollmentSchema).
help me fix the compile error in my enrollment server action
✔️ Awesome, I've got everything!
Great. Double check you have saved app/actions/enroll.ts with no errors in the terminal.
ⓧ I'd like to double check the full code
Your app/actions/enroll.ts should have these sections in order inside the submitEnrollment function:
- Three imports at the top: dbConnect, Enrollment, and enrollmentSchema.
- The honeypot check that returns fake success if the field has a value.
- The safeParse() call that validates form data and returns field errors if it fails.
- The duplicate email check using Enrollment.findOne().
- Enrollment.create(validated) using the Zod-validated data at the end.
Display validation errors in the form
The server action now returns field-level errors, but the form does not display them yet. The learner needs to see the errors to know what went wrong.
- In your EnrollmentForm component, update the submit handler to check the response from submitEnrollment.
- If the response has success: false and an errors object, store those errors in a React state variable (e.g. useState<Record<string, string>>({})).
- Below each input field, add a conditional element that shows the matching error in red text. The error keys use dot notation matching the Zod path (e.g. mother.email, child.firstName).
- Only show the success screen when the response has success: true.
Why dot notation for error keys?
Zod builds the error path from the schema's nested structure. For a field inside the mother object like email, the path becomes mother.email. Your form can look up errors['mother.email'] to find the right message for each field.
- Save your EnrollmentForm component.
- Open http://localhost:3001 in your browser. Submit the form with all fields empty. You should see red error messages appear next to each required field.
Not seeing any error messages?
Confirm your submit handler awaits the response from submitEnrollment and checks for result.errors before setting state. Make sure you are not navigating to the success screen when success is false.
help me display Zod validation errors in my EnrollmentForm component
Test the full validation flow
The validation and error display are wired up. Time to confirm each rule works: required fields, email domain filtering, and duplicate prevention.
Before you test, what do you think will happen if you enter parent@tempmail.com as the mother's email? Will it save, or will the form reject it?
- Fill in all required fields with valid data, but enter parent@tempmail.com as the mother's email address.
- Click the submit button.
The form shows a bilingual error message under the email field. The domain tempmail.com is not in the allowlist, so Zod's .refine() rejects it.
- Now change the email to a valid Gmail address (e.g. testparent@gmail.com) and submit again.
The form swaps to the success screen. The enrollment saved to MongoDB because all validation rules passed.
- Go back to the form and submit again with the same Gmail address.
The form shows a duplicate error: the email has already been used for a pre-enrollment. The server action caught the duplicate before saving a second document.
- Confirm the single enrollment was saved by running this in a new terminal:
mongosh lescaramboles --eval 'db.enrollments.find().pretty()'
You should see exactly one enrollment document with the Gmail address you submitted.
- Clear the test data by running:
mongosh lescaramboles --eval 'db.enrollments.deleteMany({})'
Why clear the test data?
The unique index on mother.email means you cannot submit another enrollment with the same email. Clearing the collection now gives you a clean slate for the next steps where you will test PDF generation and email sending.
Your form now rejects garbage data, blocks disposable email providers, and prevents duplicate submissions. Next up, you will generate a PDF summary of the enrollment and email it to the parent as a confirmation.
Generate PDF and Send Confirmation Email
Your enrollment form now validates every field, blocks disposable email providers, and prevents duplicate submissions. The database is clean. But the parent gets nothing back except a success screen.
Parents need proof of what they submitted, and the school needs a paper trail. In this step, you will generate a PDF summary of each enrollment using pdf-lib and email it to the parent using Nodemailer. For local testing, Nodemailer connects to Ethereal, a fake SMTP service that captures emails without delivering them.
In this step, get ready to:
- Build a PDF generation utility that formats enrollment data into a downloadable document.
- Build an email utility that sends a bilingual confirmation with the PDF attached.
- Wire both utilities into the server action so parents receive a receipt on every submission.
Create the PDF generation utility
pdf-lib creates PDF documents entirely in JavaScript with no native dependencies. It works in Node, the browser, and edge runtimes, making it a perfect fit for a Next.js server action.
- Create a new file called lib/generatePdf.ts inside your les-caramboles-enrollment project.
- Start by adding the import and the TypeScript interface that defines the shape of enrollment data the PDF generator will receive:
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
// Shape of enrollment data passed to the PDF generator
interface PdfData {
familySituation: string;
childLivingWith: string;
mother: {
firstName: string;
lastName: string;
email: string;
address?: string;
country?: string;
city?: string;
postalCode?: string;
phone: string;
};
father?: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
};
child: {
firstName: string;
lastName: string;
dateOfBirth: string;
requestedClass: string;
gender: string;
currentSchool?: string;
specialNeeds?: string;
professionalSupport?: string;
};
remarks?: string;
}
What does this interface do?
PdfData mirrors the enrollment fields from the Mongoose model. TypeScript uses it to ensure you pass the right data to the PDF function. Optional fields (marked with ?) only print on the PDF when they have values.
- Below the PdfData interface, add the function signature, page setup, and two helper functions that handle repetitive text drawing:
export async function generateEnrollmentPdf(data: PdfData): Promise<Buffer> {
// Create a new PDF document with an A4-sized page
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([595.28, 841.89]);
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const boldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const { height } = page.getSize();
// Track the vertical cursor position
let y = height - 50;
const leftMargin = 50;
const lineHeight = 18;
// Helper: draw a purple section title
const drawTitle = (text: string) => {
y -= lineHeight * 1.5;
page.drawText(text, { x: leftMargin, y, size: 14, font: boldFont, color: rgb(0.52, 0.16, 0.78) });
y -= lineHeight;
};
// Helper: draw a label-value pair
const drawLine = (label: string, value: string) => {
page.drawText(`${label}: ${value}`, { x: leftMargin + 10, y, size: 10, font });
y -= lineHeight;
};
What does this code do?
PDFDocument.create() creates an empty PDF in memory. addPage([595.28, 841.89]) adds an A4-sized page (those numbers are the standard A4 dimensions in PDF points).
The y variable tracks where the next line of text goes. PDFs draw from the top, so y decreases as you add content. drawTitle and drawLine are helpers that save you from repeating the same drawText options for every field.
- Still inside the function, right after the drawLine helper, add the school header that appears at the top of every generated PDF:
// Draw the school header at the top of the PDF
page.drawText('ECOLE MATERNELLE INTERNATIONALE LES CARAMBOLES', {
x: leftMargin,
y,
size: 14,
font: boldFont,
color: rgb(0.52, 0.16, 0.78),
});
y -= lineHeight;
page.drawText('Yaounde, Carrefour Regie | 657 078 888 / 652 333 103', {
x: leftMargin,
y,
size: 9,
font,
});
y -= lineHeight;
page.drawText('FICHE DE PREINSCRIPTION', {
x: leftMargin,
y,
size: 12,
font: boldFont,
});
y -= lineHeight;
page.drawText(`Date: ${new Date().toLocaleDateString('fr-FR')}`, {
x: leftMargin,
y,
size: 9,
font,
});
- Continue inside the function. Add the family, mother, and father sections that draw each field using the helpers you defined:
// Draw family situation section
drawTitle('Situation familiale');
drawLine('Situation', data.familySituation);
drawLine('Lieu de vie de l\'enfant', data.childLivingWith);
// Draw mother/guardian details
drawTitle('Mere / Responsable legal');
drawLine('Prenom', data.mother.firstName);
drawLine('Nom', data.mother.lastName);
drawLine('Email', data.mother.email);
drawLine('Telephone', data.mother.phone);
if (data.mother.address) drawLine('Adresse', data.mother.address);
if (data.mother.city) drawLine('Ville', data.mother.city);
if (data.mother.country) drawLine('Pays', data.mother.country);
if (data.mother.postalCode) drawLine('Code postal', data.mother.postalCode);
// Draw father details only if provided
if (data.father?.firstName) {
drawTitle('Pere');
if (data.father.firstName) drawLine('Prenom', data.father.firstName);
if (data.father.lastName) drawLine('Nom', data.father.lastName);
if (data.father.email) drawLine('Email', data.father.email);
if (data.father.phone) drawLine('Telephone', data.father.phone);
}
- Finish the function by adding the child section, optional remarks, and the save-and-return logic:
// Draw child details
drawTitle('Enfant');
drawLine('Prenom', data.child.firstName);
drawLine('Nom', data.child.lastName);
drawLine('Date de naissance', data.child.dateOfBirth);
drawLine('Classe demandee', data.child.requestedClass);
drawLine('Sexe', data.child.gender);
if (data.child.currentSchool) drawLine('Ecole actuelle', data.child.currentSchool);
if (data.child.specialNeeds) drawLine('Besoins specifiques', data.child.specialNeeds);
if (data.child.professionalSupport) drawLine('Suivi professionnel', data.child.professionalSupport);
// Draw remarks if any were provided
if (data.remarks) {
drawTitle('Remarques');
page.drawText(data.remarks, { x: leftMargin + 10, y, size: 10, font, maxWidth: 480 });
}
// Serialize the PDF to bytes and return as a Buffer
const pdfBytes = await pdfDoc.save();
return Buffer.from(pdfBytes);
}
What does this code do?
Each section uses drawTitle for headings and drawLine for field values. Optional fields are wrapped in if checks so they only appear on the PDF when present.
pdfDoc.save() serializes the finished document into a Uint8Array. Wrapping it in Buffer.from() converts it to a Node.js Buffer that Nodemailer can attach to an email.
- Save lib/generatePdf.ts.
- Confirm the file exists by running:
ls lib/generatePdf.ts
You should see lib/generatePdf.ts listed in the output.
File not found?
Make sure you created the file inside the lib/ folder, not in the project root. The path should be lib/generatePdf.ts.
help me find where I created my generatePdf file
✔️ Awesome, I've got everything!
Great. Make sure you have saved lib/generatePdf.ts before moving on.
ⓧ I'd like to double check the full code
Scroll up through your lib/generatePdf.ts file and confirm these sections appear in order:
- The import from pdf-lib and the PdfData interface at the top.
- The generateEnrollmentPdf function with page setup, drawTitle, and drawLine helpers.
- The school header block with the school name, address, and date.
- The data sections: family situation, mother, father (conditional), child, remarks (conditional).
- pdfDoc.save() and return Buffer.from(pdfBytes) at the end of the function.
Create the email utility
Nodemailer handles sending the email, and Ethereal provides a temporary fake SMTP server so you can test without sending real messages. Every email is captured in a web preview you can open in your browser.
Why Ethereal instead of real email?
Ethereal generates temporary SMTP credentials on the fly with no signup. Messages land in a web preview and are never delivered. This keeps development fully local and free.
nodemailer.getTestMessageUrl(info) returns a browser-viewable URL for every captured message, so you can inspect the HTML body and PDF attachment.
- Create a new file called lib/sendEmail.ts.
- Start with the import, the TypeScript interface, and the transporter setup:
import nodemailer from 'nodemailer';
// Shape of data needed to send a confirmation email
interface EmailOptions {
to: string;
parentName: string;
childName: string;
pdfBuffer: Buffer;
}
export async function sendConfirmationEmail({ to, parentName, childName, pdfBuffer }: EmailOptions) {
// Create a temporary Ethereal SMTP account for local email testing
const testAccount = await nodemailer.createTestAccount();
// Set up the transport with the test credentials
const transporter = nodemailer.createTransport({
host: testAccount.smtp.host,
port: testAccount.smtp.port,
secure: testAccount.smtp.secure,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
const whatsappLink = process.env.NEXT_PUBLIC_WHATSAPP_LINK || 'https://chat.whatsapp.com/your-invite-code-here';
What does this code do?
nodemailer.createTestAccount() calls Ethereal's API and returns temporary SMTP credentials. The transporter uses these to connect to smtp.ethereal.email on port 587. No account creation or configuration is required.
- Right after the whatsappLink line, add the sendMail call that sends the bilingual confirmation email with the PDF attached:
// Send the confirmation email with the PDF attached
const info = await transporter.sendMail({
from: '"Les Caramboles" <inscription@lescaramboles.com>',
to,
subject: `Confirmation de preinscription - ${childName} | Pre-enrollment confirmation - ${childName}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #8529c6;">Les Caramboles - Confirmation de preinscription</h2>
<p>Bonjour ${parentName},</p>
<p>Nous avons bien recu votre demande de preinscription pour <strong>${childName}</strong>.</p>
<p>Vous trouverez ci-joint un recapitulatif de votre demande au format PDF.</p>
<p>Notre equipe examinera votre dossier et vous contactera prochainement.</p>
<hr style="border: 1px solid #f2daff; margin: 20px 0;" />
<p><strong>Rejoignez notre communaute WhatsApp pour recevoir les actualites de l'ecole :</strong></p>
<a href="${whatsappLink}" style="display: inline-block; background-color: #25D366; color: white; padding: 10px 20px; border-radius: 8px; text-decoration: none; font-weight: bold;">Rejoindre sur WhatsApp</a>
<hr style="border: 1px solid #f2daff; margin: 20px 0;" />
<p style="font-size: 12px; color: #7f7384;">
Ecole Maternelle Internationale Les Caramboles<br/>
Yaounde, Carrefour Regie<br/>
Tel : 657 078 888 / 652 333 103<br/>
lescaramboles2021@gmail.com
</p>
</div>
`,
attachments: [
{
filename: `preinscription-${childName.toLowerCase().replace(/\s+/g, '-')}.pdf`,
content: pdfBuffer,
contentType: 'application/pdf',
},
],
});
// Log the Ethereal preview URL so you can view the email in a browser
const previewUrl = nodemailer.getTestMessageUrl(info);
console.log('Email preview URL:', previewUrl);
return { messageId: info.messageId, previewUrl };
}
What does this code do?
transporter.sendMail() sends the email with a bilingual subject line and an HTML body that includes a thank-you message, school contact details, and a WhatsApp community invite button.
The attachments array attaches the PDF Buffer with a filename based on the child's name. nodemailer.getTestMessageUrl(info) returns the Ethereal preview URL that you will use to inspect the sent email in your browser.
- Save lib/sendEmail.ts.
- Confirm both new files exist by running:
ls lib/generatePdf.ts lib/sendEmail.ts
You should see both filenames listed in the output.
✔️ Awesome, I've got everything!
Great. Make sure you have saved lib/sendEmail.ts before moving on.
ⓧ I'd like to double check the full code
Scroll up through your lib/sendEmail.ts file and confirm these sections appear in order:
- The import from nodemailer and the EmailOptions interface.
- The sendConfirmationEmail function with the test account setup and transporter creation.
- The sendMail call with the bilingual HTML body, WhatsApp link, and PDF attachment.
- The console.log that prints the Ethereal preview URL.
Wire the utilities into the server action
The PDF generator and email sender are ready, but the server action does not use them yet. Enrollments still save silently with no receipt sent to the parent.
- In app/actions/enroll.ts, find the existing imports at the top of the file:
import { enrollmentSchema } from '@/lib/enrollmentSchema';
- Add two new import lines directly below it, so the imports now look like this:
import { enrollmentSchema } from '@/lib/enrollmentSchema';
import { generateEnrollmentPdf } from '@/lib/generatePdf';
import { sendConfirmationEmail } from '@/lib/sendEmail';
- Now scroll down to the bottom of the submitEnrollment function and find this line:
await Enrollment.create(validated);
return { success: true };
- Replace the return { success: true }; line with the PDF generation and email sending code, so the end of the function now looks like this:
await Enrollment.create(validated);
// Generate PDF summary of the enrollment
const pdfBuffer = await generateEnrollmentPdf({
familySituation: validated.familySituation,
childLivingWith: validated.childLivingWith,
mother: validated.mother,
father: validated.father,
child: validated.child,
remarks: validated.remarks,
});
// Send confirmation email with PDF attachment
await sendConfirmationEmail({
to: validated.mother.email,
parentName: `${validated.mother.firstName} ${validated.mother.lastName}`,
childName: `${validated.child.firstName} ${validated.child.lastName}`,
pdfBuffer,
});
return { success: true };
What does this code do?
After a successful save to MongoDB, the server action now generates a PDF from the validated data and emails it to the parent. The full flow is: honeypot check, Zod validation, duplicate email check, save to database, generate PDF, send email, return success.
- Save app/actions/enroll.ts.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before testing.
ⓧ I'd like to double check the full code
Scroll through your app/actions/enroll.ts and confirm these pieces are in order:
- Five imports at the top: dbConnect, Enrollment, enrollmentSchema, generateEnrollmentPdf, and sendConfirmationEmail.
- Honeypot check, Zod validation, duplicate email check, and Enrollment.create(validated) are all unchanged.
- After the create: generateEnrollmentPdf() call, then sendConfirmationEmail() call, then return { success: true }.
Time to test the full pipeline. Before you submit, what do you think will happen when you fill in the form and click submit? Will you see anything new in the terminal?
- First, clear any test enrollments from previous steps by running:
mongosh lescaramboles --eval 'db.enrollments.deleteMany({})'
- Open http://localhost:3001 in your browser.
- Fill in the enrollment form with valid data. Use a real-format email like parent@gmail.com for the mother's email.
- Submit the form.
The form swaps to the success screen with the WhatsApp button. Now switch to your terminal where the dev server is running.
- Look for the Email preview URL: line in the terminal output. It starts with https://ethereal.email/message/.
- Copy that URL and open it in your browser.
The Ethereal preview shows the bilingual confirmation email with the school header, the thank-you message, and the WhatsApp community button. You should also see a PDF attachment at the bottom of the email.
- Download and open the PDF attachment. It contains the school header, the parent and child details you submitted, and today's date.
No preview URL in the terminal?
Make sure you saved both lib/generatePdf.ts and lib/sendEmail.ts. If the dev server shows a TypeScript error, check that the import paths in app/actions/enroll.ts match the exact file names (@/lib/generatePdf and @/lib/sendEmail).
If the form submission hangs or fails silently, check that your MongoDB service is running. Ethereal also requires an internet connection since it creates test accounts over the network.
help me debug why the Ethereal preview URL is not appearing
Parents now receive a confirmation email with a PDF record of their enrollment. The form validates, saves, generates, and emails in one clean pipeline. Next up, you will add anti-spam protection to stop bots from flooding the database with fake submissions.
Add Honeypot Anti-Spam
The enrollment form validates data, saves to MongoDB, and sends a PDF confirmation email. But how well does it handle bots? Automated scripts can flood the database with fake enrollments and consume the school's email quota.
Your server action already has a basic honeypot check from when you built it, and the form includes a hidden input from Step 2. In this step, you will add inline comments to document the anti-spam logic and prove the honeypot actually catches bots.
In this step, get ready to:
- Add inline comments documenting the honeypot check in the server action.
- Test that a bot-like submission is silently discarded.
- Verify that normal human submissions still process correctly.
Document the honeypot logic with comments
A honeypot is a hidden form field with CSS display:none that humans never see. Bots auto-fill every field on a page, including hidden ones. When the server detects a filled honeypot, it silently discards the submission while returning a fake success response.
- In app/actions/enroll.ts, find the honeypot check at the top of the submitEnrollment function.
- Find this code:
if (formData.honeypot) {
return { success: true };
}
- Add comments above and inside the if block so it looks like this:
// Honeypot check: if the hidden field has any value, a bot filled it
if (formData.honeypot) {
// Return fake success so the bot thinks it worked
return { success: true };
}
What is a honeypot?
The hidden input has CSS display:none, so real users never see it and leave it empty. Bots scan the page's HTML and auto-fill every input field, including hidden ones.
The server checks if the honeypot has any value. If it does, the submission came from a bot. Returning { success: true } tricks the bot into thinking it succeeded, so it does not retry with a different strategy.
- Still in the same file, scroll down and add // Validate with Zod on the blank line above const result = enrollmentSchema.safeParse(formData);.
- Add // Check for duplicate email above const existing = await Enrollment.findOne(...).
- Add // Save to MongoDB above await Enrollment.create(validated);.
- Add // Generate PDF above const pdfBuffer = await generateEnrollmentPdf(...).
- Add // Send confirmation email with PDF attachment above await sendConfirmationEmail(...).
- Save app/actions/enroll.ts.
✔️ Awesome, I've got everything!
Great. Make sure you have saved app/actions/enroll.ts before moving on.
ⓧ I'd like to double check the full code
Scroll through app/actions/enroll.ts and confirm you see these comments in order:
- // Honeypot check: if the hidden field has any value, a bot filled it above the if (formData.honeypot) check.
- // Return fake success so the bot thinks it worked inside the if block.
- // Validate with Zod above safeParse.
- // Check for duplicate email above findOne.
- // Save to MongoDB above Enrollment.create.
- // Generate PDF above generateEnrollmentPdf.
- // Send confirmation email with PDF attachment above sendConfirmationEmail.
Test the honeypot with DevTools
Time to prove the honeypot works. You will simulate a bot by manually filling the hidden field through your browser's developer tools, then confirm the submission is silently discarded.
- Clear any test enrollments from previous steps by running this command:
mongosh lescaramboles --eval 'db.enrollments.deleteMany({})'
- Confirm the collection is empty by running:
mongosh lescaramboles --eval 'db.enrollments.countDocuments()'
You should see 0. This is your baseline.
- Open http://localhost:3001 in your browser.
- Open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Elements tab in DevTools.
- Press Cmd+F (macOS) or Ctrl+F (Windows) in the Elements panel to open the search bar.
- Type honeypot and press Enter to find the hidden input element.
- Double-click the input element's value attribute in the Elements panel and type bot-test.
- Fill in the visible form fields with valid data (use a Gmail address for the mother's email).
- Click the submit button.
The form shows the success screen with the WhatsApp button. The "bot" thinks it won.
Before you check the database, do you think the enrollment was actually saved, or was it silently discarded?
- Check the enrollment count by running:
mongosh lescaramboles --eval 'db.enrollments.countDocuments()'
The count is still 0. The honeypot caught the fake submission and discarded it without saving to MongoDB, generating a PDF, or sending an email.
Why return fake success?
If you returned an error, the bot would know it was detected and try again with a different strategy. By returning { success: true }, the bot moves on, thinking it succeeded. The school's database stays clean.
Confirm normal submissions still work
You have proven the honeypot catches bots. Now make sure it does not interfere with real parent submissions.
- Refresh the page at http://localhost:3001.
- Fill in the form with valid data. Use a Gmail address for the mother's email.
- Do NOT open DevTools or touch the honeypot field this time.
- Click the submit button.
The success screen appears with the WhatsApp community button. Check your terminal for the Ethereal preview URL to confirm the email was sent.
Do you expect the count to have changed this time?
- Verify the enrollment was saved by running:
mongosh lescaramboles --eval 'db.enrollments.countDocuments()'
The count is now 1. The normal submission passed through the honeypot check, was validated by Zod, saved to MongoDB, and triggered the PDF email. The honeypot only blocks bots, not real parents.
Count did not increase?
- If you still had DevTools open from the previous test, the honeypot input may still have the bot-test value. Refresh the page fully (close DevTools first) to reset all form fields.
- If you see a validation error about the email already being used, clear the test data again with mongosh lescaramboles --eval 'db.enrollments.deleteMany({})' and resubmit.
- Make sure your dev server is still running on port 3001. If it stopped, restart it with npm run dev.
Why isn't my normal enrollment submission saving to MongoDB?
Your enrollment form now silently catches bots without disrupting real parents. The honeypot is a lightweight first line of defense that requires no external services. If you want to add a more sophisticated layer of bot detection, the Secret Mission has you covered.
Secret mission
Add reCAPTCHA v3 Bot Detection
The honeypot catches simple bots, but sophisticated bots can detect hidden fields and skip them. Add reCAPTCHA v3 for invisible, score-based bot detection that runs in the background without interrupting real parents with puzzles or checkboxes.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with no ongoing costs. Decide whether to keep your resources, pause them for later, or delete them entirely.
Resources you used:
- MongoDB enrollments collection in the lescaramboles database (test enrollment documents you submitted during testing).
- les-caramboles-enrollment project folder on your Desktop.
Keep everything running
No action needed. Choose this if you plan to continue with Project 5 (LMS) or want to keep experimenting with the enrollment form.
- Your MongoDB data and project files stay in place.
- MongoDB can keep running in the background with minimal resource usage.
- If you completed the Secret Mission, your reCAPTCHA v3 site registration stays active at no cost (10,000 free assessments per month).
- Ethereal test email accounts are temporary and expire on their own.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and data stay exactly where you left them.
- Stop the dev server by pressing Ctrl+C in the terminal where it is running.
- MongoDB can keep running in the background. Your enrollment data will be exactly where you left it when you return.
Delete - I don't want to use this again
Remove all test data and project files to start fresh.
- Drop all test enrollment documents from MongoDB by running:
mongosh lescaramboles --eval 'db.enrollments.deleteMany({})'
- Remove the entire project folder. On macOS or Linux, run:
rm -rf ~/Desktop/les-caramboles-enrollment
- On Windows (PowerShell), run:
Remove-Item -Recurse -Force ~\Desktop\les-caramboles-enrollment
Nice Work!
Nice Work!
You just built a production-ready enrollment form that replaces the school's JotForm.
Parents can now fill in their family details, submit, and receive a confirmation email with a PDF summary attached.
You've learned how to:
- Build a bilingual enrollment form with Next.js server actions that validates every field through Zod, filters email domains against an allowlist, and prevents duplicate submissions at the MongoDB database level.
- Generate PDF enrollment summaries with pdf-lib and send them as email attachments via Nodemailer, using Ethereal for safe local testing.
- Protect the form from spam with a honeypot field that silently discards bot submissions without alerting the attacker.
- Completed a Secret Mission to add reCAPTCHA v3 invisible bot detection with score-based verification through Google's API.
Ready to quiz yourself?