Blog Routes, Events and RSVP
Add blog detail pages, an events calendar, and RSVP to a school site.
Introduction
30 Second Summary
Every school has a website, but most of them feel like a bulletin board stuck behind glass. Parents click around looking for event details and hit dead ends.
In this project, you will extend the Les Caramboles school website with full blog article pages, a filterable events calendar, and an RSVP system that saves registrations to a database.
What You'll Build
Click a blog card and read the full French article, browse upcoming school events in a filterable list, then RSVP and watch the confirmation message replace the form.
By the end of this project, you'll have:
- Click any blog card on the Blog page and land on a full bilingual article with the title, formatted date, category badge, and complete content. Hit a bad URL and get a proper 404 page.
- A filterable Events page that splits school events into Upcoming and Past tabs with a single click. Each event card shows the date, time, location, and a bilingual description.
- A working RSVP system powered by a server action that validates input, saves registrations to MongoDB, and swaps the form for a confirmation message.
- Secret Mission: Add a Related Posts section to the blog detail page that shows other posts from the same category.
Are there any prerequisites?
This project builds on Project 2 (Extend the Les Caramboles Website). You need the les-caramboles-frontend project with all Project 2 code complete, MongoDB running locally, and Node.js installed.
Before We Start
Before we dive in, take a moment to think about what you're building and why the school website needs it. Putting this into words now helps you stay focused through the steps ahead.
Create the Event Model and Seed Event Data
The Events page you are about to build needs data. Before you can display school events on the website, MongoDB needs to know what an event looks like. That means creating a Mongoose model that defines the shape of every event document.
In this step, you will create two new models (Event and Rsvp) and seed your database with sample school events. By the end, you will have real data ready for the Events page.
In this step, get ready to:
- Create a Mongoose model for school events.
- Create a Mongoose model for RSVPs.
- Seed 4 sample events into MongoDB.
Create the Event model
Each school event has a bilingual title, description, and location (French and English), a date, a time, a category, and a published flag. The Event model tells Mongoose the exact shape of every event document stored in MongoDB.
- In VS Code, right-click the models folder in the Explorer sidebar.
- Click New File and name it Event.ts.
- Paste the following code into models/Event.ts:
import mongoose, { Schema, model, models } from 'mongoose';
const EventSchema = new Schema({
// Bilingual title, description, and location (fr + en)
title: { fr: { type: String, required: true }, en: { type: String, required: true } },
description: { fr: { type: String, required: true }, en: { type: String, required: true } },
date: { type: Date, required: true },
time: { type: String, required: true },
location: { fr: { type: String, required: true }, en: { type: String, required: true } },
category: { type: String },
published: { type: Boolean, default: true }
});
// Guard prevents model recompilation during Next.js hot reloading
export const Event = models.Event ?? model('Event', EventSchema);
What does this code do?
The schema defines seven fields. Three of them (title, description, location) are bilingual objects with fr and en sub-fields. This is the same pattern your existing models use for the school's French and English content.
The time field is a string (like 14h00 - 16h00) because it is a display label, not a computable timestamp. The date field handles actual date sorting and filtering.
The export line uses the models.Event ?? model('Event', EventSchema) guard. This prevents Mongoose from trying to recompile the same model every time Next.js hot-reloads your code during development.
- Save models/Event.ts.
- In the Explorer sidebar, expand the models folder. You should see Event.ts listed alongside your existing model files.
Don't see Event.ts in the models folder?
- Make sure you created the file inside the models folder, not at the project root. The file path should be models/Event.ts.
- Check the filename is exactly Event.ts with a capital E and a .ts extension.
- Help me find where my Event.ts file ended up.
✔️ Awesome, I've got everything!
Make sure you have saved models/Event.ts. One model down, one to go.
ⓧ I'd like to double check the full code
Here is the complete models/Event.ts file. Compare it character by character with yours:
import mongoose, { Schema, model, models } from 'mongoose';
const EventSchema = new Schema({
title: { fr: { type: String, required: true }, en: { type: String, required: true } },
description: { fr: { type: String, required: true }, en: { type: String, required: true } },
date: { type: Date, required: true },
time: { type: String, required: true },
location: { fr: { type: String, required: true }, en: { type: String, required: true } },
category: { type: String },
published: { type: Boolean, default: true }
});
export const Event = models.Event ?? model('Event', EventSchema);
Create the Rsvp model
Parents will RSVP to events by submitting their name and email. That RSVP data needs its own model, separate from Event, because RSVPs grow over time while event details stay relatively fixed.
- Right-click the models folder again and click New File.
- Name it Rsvp.ts and paste the following code:
import mongoose, { Schema, model, models } from 'mongoose';
const RsvpSchema = new Schema({
// Links this RSVP to a specific event
eventId: { type: Schema.Types.ObjectId, ref: 'Event', required: true },
name: { type: String, required: true },
email: { type: String, required: true },
// Automatically records when the RSVP was submitted
createdAt: { type: Date, default: Date.now }
});
export const Rsvp = models.Rsvp ?? model('Rsvp', RsvpSchema);
What does this code do?
The eventId field uses Schema.Types.ObjectId with a ref: 'Event' reference. This tells Mongoose that each RSVP belongs to one Event document. It is a foreign key relationship between two collections.
The createdAt field defaults to Date.now, so Mongoose automatically records when the RSVP was submitted without requiring the caller to pass a timestamp.
Why is Rsvp a separate model from Event?
RSVPs grow over time as more parents sign up. If RSVPs were embedded inside each Event document, the document would keep getting larger with every registration. Keeping them in a separate collection means Event documents stay small and RSVP queries stay simple.
- Save models/Rsvp.ts.
- In the Explorer sidebar, expand the models folder. You should see both Event.ts and Rsvp.ts listed alongside your existing model files.
Missing Rsvp.ts in the models folder?
- Double-check the filename is Rsvp.ts with a capital R. Mongoose model files in this project use PascalCase naming.
- Make sure the file is inside the models folder, not the project root or another directory.
- Help me find where my Rsvp.ts ended up.
✔️ Awesome, I've got everything!
Make sure you have saved models/Rsvp.ts. Both models are ready.
ⓧ I'd like to double check the full code
Here is the complete models/Rsvp.ts file:
import mongoose, { Schema, model, models } from 'mongoose';
const RsvpSchema = new Schema({
eventId: { type: Schema.Types.ObjectId, ref: 'Event', required: true },
name: { type: String, required: true },
email: { type: String, required: true },
createdAt: { type: Date, default: Date.now }
});
export const Rsvp = models.Rsvp ?? model('Rsvp', RsvpSchema);
Seed event data into MongoDB
Models define the shape, but your database is still empty. You need to seed sample events so there is something to display when you build the Events page. Your project already has a seed script from Project 2. You will add the new models and sample data to it.
- Open lib/seed.ts in VS Code.
- Find your existing model imports at the top of the file (like BlogPost, GalleryItem, etc.).
- Add these two import lines next to them:
import { Event } from '../models/Event';
import { Rsvp } from '../models/Rsvp';
- Below your existing sample data arrays (like sampleBlogPosts), paste this array of school events:
// Sample events: 2 upcoming (future dates), 2 past (past dates)
const sampleEvents = [
{
title: { fr: 'Journee portes ouvertes', en: 'Open House Day' },
description: { fr: 'Venez decouvrir notre ecole et rencontrer les enseignants.', en: 'Come discover our school and meet the teachers.' },
date: new Date('2027-03-15'), time: '10h00 - 12h00',
location: { fr: 'Cour principale', en: 'Main courtyard' },
category: 'General', published: true,
},
{
title: { fr: 'Fete des familles', en: 'Family Festival' },
description: { fr: 'Spectacles, jeux et gouter pour celebrer ensemble.', en: 'Shows, games, and snacks to celebrate together.' },
date: new Date('2027-06-28'), time: '14h00 - 17h00',
location: { fr: 'Salle polyvalente', en: 'Multipurpose hall' },
category: 'Celebration', published: true,
},
{
title: { fr: 'Atelier jardinage', en: 'Gardening Workshop' },
description: { fr: 'Les enfants apprennent a planter des legumes au potager.', en: 'Children learn to plant vegetables in the school garden.' },
date: new Date('2025-11-10'), time: '09h00 - 11h00',
location: { fr: 'Jardin scolaire', en: 'School garden' },
category: 'Nature', published: true,
},
{
title: { fr: 'Concert de Noel', en: 'Christmas Concert' },
description: { fr: 'Les eleves presentent leurs chants et musiques de Noel.', en: 'Students perform their Christmas songs and music.' },
date: new Date('2025-12-18'), time: '18h00 - 19h30',
location: { fr: 'Auditorium', en: 'Auditorium' },
category: 'Music', published: true,
},
];
What does this data do?
The first two events have dates in 2027, which is after today. These will appear in the Upcoming tab on the Events page. The last two events have dates in 2025, which is before today. These will appear in the Past tab.
Each event has bilingual fields matching the Event schema you just created. The time field stores a display string like 14h00 - 17h00 while the date field stores an actual JavaScript Date for sorting and filtering.
- Inside your seed() function, find your existing seed calls (like BlogPost.deleteMany({}) and BlogPost.insertMany()).
- Add these lines after the existing seed calls to seed events:
// Clear old events and seed fresh data
await Event.deleteMany({});
await Event.insertMany(sampleEvents);
console.log('Seeded', sampleEvents.length, 'events');
What does this code do?
The deleteMany({}) call wipes any existing event documents first. This means you can run the seed script multiple times without duplicating data. Then insertMany(sampleEvents) writes all 4 events to MongoDB in one operation.
- Save lib/seed.ts.
Before you run the seed script, think about what you expect to see in the terminal output. How many events should be seeded?
- Run the seed script by running this command in your terminal:
npx tsx --env-file=.env.local lib/seed.ts
You should see Seeded 4 events in the terminal output, alongside the existing seed messages from your other models.
- Confirm the events are actually in MongoDB by running:
mongosh lescaramboles --eval 'db.events.find().pretty()'
You should see 4 event documents printed out. Each one has a bilingual title, description, and location, plus a date, time, and category.
Seeing an error when running the seed script?
- If you see a connection error, make sure MongoDB is running locally. Open a separate terminal and run mongosh to verify the connection.
- If you see an import error for Event or Rsvp, check that the import paths match your file locations exactly. The imports should point to ../models/Event and ../models/Rsvp (relative from lib/).
- If you see a syntax error in the sampleEvents array, check for missing commas between event objects and matching curly braces.
- Help me debug my seed script error.
Your database now has 4 school events ready to display. Next up, you will turn those dead-end blog cards into full article pages by building dynamic routes with Mongoose queries.
Build Individual Blog Post Pages
In Step 1, you created the Event and Rsvp models and seeded sample data into MongoDB. The database is ready for the Events page you will build later.
But there is a gap on your Blog page right now. Four beautiful cards sit there with bilingual titles and excerpts, but clicking any of them does absolutely nothing. Each card is a dead end with no article page behind it.
This step fixes that. You will create dynamic blog post pages at /blog/[slug] that load the right article from MongoDB, and then wire the cards to link there.
In this step, get ready to:
- Create a dynamic route to load individual blog posts from MongoDB.
- Build a component to render the full bilingual article.
- Link blog cards to their detail pages.
Create the dynamic blog post route
Next.js uses folder-based routing. A folder named [slug] creates a dynamic route segment that captures whatever value appears in the URL. When someone visits /blog/journee-nature, Next.js passes journee-nature as the slug parameter to your page component.
- In VS Code, create a new folder path inside your app/[locale]/blog/ directory: a folder named [slug].
- Inside app/[locale]/blog/[slug]/, create a new file called page.tsx and add the imports and metadata function by copying this code:
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { dbConnect } from '@/lib/dbConnect';
import { BlogPost } from '@/models/BlogPost';
import BlogDetail from '@/components/BlogDetail';
// Generate bilingual SEO metadata from the blog post's fields
export async function generateMetadata({ params }: { params: Promise<{ locale: string; slug: string }> }): Promise<Metadata> {
const { locale, slug } = await params;
await dbConnect();
const post = await BlogPost.findOne({ slug }).lean();
if (!post) return { title: 'Not Found' };
const lang = locale as 'fr' | 'en';
return { title: post.title[lang], description: post.excerpt[lang] };
}
What does this code do?
The imports pull in everything the page needs: database connection, the BlogPost model, the detail component, and Next.js helpers for metadata and 404 handling.
generateMetadata runs on the server before the page renders. It fetches the blog post by slug, then returns the bilingual title and excerpt as the page's <title> and <meta description> tags for SEO.
- Below generateMetadata, add the page component that loads and renders the blog post:
export default async function BlogPostPage({ params }: { params: Promise<{ locale: string; slug: string }> }) {
const { locale, slug } = await params;
await dbConnect();
// Find the blog post matching this URL's slug
const post = await BlogPost.findOne({ slug }).lean();
// Show a 404 page if no post matches
if (!post) notFound();
// Convert MongoDB types to plain strings for the client component
const serialized = { ...post, _id: String(post._id), date: post.date.toISOString() };
return (
<>
<BlogDetail post={serialized} locale={locale} />
</>
);
}
What does this page component do?
- await params extracts both locale and slug from the URL. In Next.js 16, params is a Promise that must be awaited.
- BlogPost.findOne({ slug }).lean() queries MongoDB for a single document matching the slug. The .lean() call returns a plain JavaScript object instead of a full Mongoose document, which is faster and simpler when you only need to read data.
- notFound() triggers the closest not-found.tsx boundary or the default 404 page if the slug does not match any post.
- Serialization converts _id (a MongoDB ObjectId) and date (a Date object) to plain strings so they can be passed from the server to the client component.
- Save app/[locale]/blog/[slug]/page.tsx. The page needs a BlogDetail component to render, which you will create next.
✔️ Awesome, I've got everything!
Great. Make sure you have saved page.tsx before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/[locale]/blog/[slug]/page.tsx file. Make sure yours matches:
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { dbConnect } from '@/lib/dbConnect';
import { BlogPost } from '@/models/BlogPost';
import BlogDetail from '@/components/BlogDetail';
export async function generateMetadata({ params }: { params: Promise<{ locale: string; slug: string }> }): Promise<Metadata> {
const { locale, slug } = await params;
await dbConnect();
const post = await BlogPost.findOne({ slug }).lean();
if (!post) return { title: 'Not Found' };
const lang = locale as 'fr' | 'en';
return { title: post.title[lang], description: post.excerpt[lang] };
}
export default async function BlogPostPage({ params }: { params: Promise<{ locale: string; slug: string }> }) {
const { locale, slug } = await params;
await dbConnect();
const post = await BlogPost.findOne({ slug }).lean();
if (!post) notFound();
const serialized = { ...post, _id: String(post._id), date: post.date.toISOString() };
return (
<>
<BlogDetail post={serialized} locale={locale} />
</>
);
}
Build the blog detail component
The page route fetches data from MongoDB, but it needs a component to actually display the article. This component will render the back link, category badge, formatted date, bilingual title, and full content.
- Create a new file called components/BlogDetail.tsx and add the imports and interface:
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
// Define the shape of a blog post passed from the server
interface BlogDetailProps {
post: {
_id: string;
slug: string;
title: { fr: string; en: string };
excerpt: { fr: string; en: string };
content: { fr: string; en: string };
date: string;
imageUrl?: string;
category?: string;
};
locale: string;
}
Why is this a client component?
The 'use client' directive makes this a client component so it can use the useTranslations hook from next-intl. The interface defines every field the component expects, matching the serialized post shape from the page route.
- Below the interface, add the component function that renders the full article:
export default function BlogDetail({ post, locale }: BlogDetailProps) {
const t = useTranslations('BlogPage');
const lang = locale as 'fr' | 'en';
// Format the date based on the current locale
const formattedDate = new Date(post.date).toLocaleDateString(
locale === 'fr' ? 'fr-FR' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric' }
);
return (
<article className="px-4 md:px-16 py-16 max-w-3xl mx-auto">
<Link href="/blog" className="text-[#8529c6] text-sm font-semibold mb-6 inline-block hover:underline">
{t('backToList')}
</Link>
{post.category && (
<span className="inline-block px-4 py-1 rounded-full bg-[#f2daff] text-[#6d00ad] text-sm font-semibold mb-4 ml-4">
{post.category}
</span>
)}
<p className="text-sm text-[#7f7384] mb-4">{formattedDate}</p>
<h1 className="text-3xl md:text-[44px] font-extrabold leading-tight mb-6" style={{ fontFamily: 'Plus Jakarta Sans' }}>
{post.title[lang]}
</h1>
<div className="text-[#4d4353] text-lg leading-relaxed" style={{ fontFamily: 'Be Vietnam Pro' }}>
{post.content[lang]}
</div>
</article>
);
}
What does this component render?
- A purple back link to /blog using a translated label from the BlogPage namespace.
- A category badge displayed as a rounded purple pill (only if the post has a category).
- A locale-aware formatted date. French users see 15 mars 2025 while English users see March 15, 2025.
- The bilingual title as an h1 and the full content, both using the current locale to pick the right language.
The component uses t('backToList') for the back link text, but that translation key does not exist yet. You need to add it to both language files.
- Open messages/fr.json and find the BlogPage object. Add the following key inside it:
"backToList": "Retour au blog"
- Open messages/en.json and add the matching key inside its BlogPage object:
"backToList": "Back to blog"
- Save BlogDetail.tsx, messages/fr.json, and messages/en.json.
- Make sure your dev server is running. If it is not, start it by running:
npm run dev
- Open your browser and navigate directly to http://localhost:3000/fr/blog/journee-nature.
You should see the full article page with a purple "Retour au blog" back link, a category badge, the formatted date in French, the French title as a large heading, and the full article content.
- Now change the URL to http://localhost:3000/en/blog/journee-nature and confirm the same article appears with its English title, English content, and an English-formatted date.
- Try a slug that does not exist, like http://localhost:3000/fr/blog/fake-post. You should see a 404 page instead of a crash.
Seeing a server error instead of the article?
- Check that components/BlogDetail.tsx exists and is saved. The page route imports this component, so a missing file causes a build error.
- Verify the folder path is exactly app/[locale]/blog/[slug]/page.tsx with square brackets included in the folder names. Missing brackets means Next.js treats them as static segments, not dynamic ones.
- Make sure MongoDB is running locally. The page calls dbConnect() on every request.
Why isn't my blog detail page loading?
✔️ Awesome, I've got everything!
Great. Confirm you have saved all three files before continuing.
ⓧ I'd like to double check the full code
Your BlogDetail.tsx file has two sections. Scroll up and confirm both are present in order:
- The 'use client' directive, imports, and the BlogDetailProps interface.
- The BlogDetail function with the back link, category badge, formatted date, title, and content.
Link blog cards to their detail pages
Your article pages work when you type the URL directly. But the blog cards on /fr/blog still do not link anywhere. Try clicking one right now. Nothing happens.
The fix is wrapping each card in a Next.js Link component that points to /blog/${post.slug}.
- Open components/BlogCard.tsx and find the import block at the top of the file.
- Find this line:
import Image from 'next/image';
- Add a new import for Link directly below it, so the two lines look like this:
import Image from 'next/image';
import Link from 'next/link';
- Now scroll down to the return statement in the component function.
- Find this line:
return (
<motion.article initial={{ opacity: 0, y: 30 }}
- Wrap the <motion.article> inside a <Link> component, so the return now looks like this:
return (
<Link href={`/blog/${post.slug}`}>
<motion.article initial={{ opacity: 0, y: 30 }}
- At the bottom of the return, find the closing </motion.article> and add a closing </Link> tag right after it. Find this:
</motion.article>
);
- Update it to:
</motion.article>
</Link>
);
What does wrapping in Link do?
The Link component from next/link enables client-side navigation without a full page reload. Each card now links to /blog/${post.slug}, which matches the [slug] dynamic route you created in the previous substep.
- Save BlogCard.tsx.
Before you test, think about what you expect to happen. When you click a blog card on /fr/blog, where do you think the browser will navigate? What will you see on the page?
- Navigate to http://localhost:3000/fr/blog in your browser.
- Click any blog card.
You should land on the full article page with the French title, a formatted date, and the complete article content. The URL changes to /fr/blog/journee-nature (or whichever card you clicked).
- Switch to the English version by changing /fr/ to /en/ in the URL. Confirm the same article appears with its English title and content.
Clicking a card does nothing?
- Make sure you saved BlogCard.tsx after adding the Link wrapper. Unsaved changes will not be picked up by the dev server.
- Check that the <Link> wraps the entire <motion.article> element. If the </Link> closing tag is in the wrong position, the card may not be clickable.
- Verify you added import Link from 'next/link'; at the top of the file. A missing import causes a build error that prevents the page from loading.
Why aren't my blog cards linking to the detail page?
✔️ Awesome, I've got everything!
Great. Make sure you have saved BlogCard.tsx before continuing.
ⓧ I'd like to double check the full code
Your BlogCard.tsx has two changes from the original. Scroll up and confirm both are present:
- The import Link from 'next/link'; line is present in the imports block.
- The return statement opens with <Link href={`/blog/${post.slug}`}> wrapping the entire <motion.article>, with </Link> closing after </motion.article>.
Every blog card is now a working link. Parents can browse articles, read them in French or English, and get a proper 404 for bad URLs. Next up, you will build an Events page where parents can see what is happening at the school.
Build the Events Page with Date Filtering
Blog posts are alive now. Every card links to a full article, and bad URLs get a clean 404. But there's still a gap in the school website: parents have no way to find out what's happening at Les Caramboles.
In this step, you'll build an Events page that fetches school events from MongoDB and splits them into Upcoming and Past tabs. The real learning here is the date filter: you'll compare each event's date to new Date() on the client to decide which tab it belongs in.
In this step, get ready to:
- Add bilingual translation keys for the Events page.
- Create an EventList client component with tab filtering and animated event cards.
- Create the Events page server component that fetches and serializes events from MongoDB.
Add the EventsPage translation keys
The Events page and its component both pull text from translation files using next-intl. Without these keys in place first, the page would show raw key names instead of proper French or English text.
- Open messages/fr.json in VS Code.
- Add the following EventsPage namespace alongside your existing namespaces (like BlogPage):
"EventsPage": {
"title": "Événements",
"heading": "Événements",
"description": "Découvrez les événements de l'école Les Caramboles.",
"tabUpcoming": "À venir",
"tabPast": "Passé",
"noEvents": "Aucun événement pour le moment.",
"rsvpButton": "S'inscrire",
"rsvpSuccess": "Inscription confirmée !",
"rsvpNamePlaceholder": "Votre nom",
"rsvpEmailPlaceholder": "Votre email",
"missingFields": "Veuillez remplir tous les champs.",
"invalidEmail": "Adresse email invalide."
}
What are all these keys for?
The tab keys control the filter button labels. The rsvp keys will power the RSVP form you'll build in the next step. Adding them now means you won't need to touch these files again later.
- Still in messages/fr.json, find your existing Nav namespace and add a link for the Events page:
"events": "Événements"
- Save messages/fr.json.
- Now open messages/en.json and add the same EventsPage namespace in English:
"EventsPage": {
"title": "Events",
"heading": "Events",
"description": "Discover upcoming events at Les Caramboles school.",
"tabUpcoming": "Upcoming",
"tabPast": "Past",
"noEvents": "No events at the moment.",
"rsvpButton": "RSVP",
"rsvpSuccess": "RSVP confirmed!",
"rsvpNamePlaceholder": "Your name",
"rsvpEmailPlaceholder": "Your email",
"missingFields": "Please fill in all fields.",
"invalidEmail": "Invalid email address."
}
- Add the Events nav link to the Nav namespace in messages/en.json as well:
"events": "Events"
- Save messages/en.json.
Create the EventList client component
The EventList component handles everything the user interacts with on the Events page: tab switching, date filtering, and rendering each event card. It needs to be a client component because useState tracks which tab is active.
- Create a new file at components/EventList.tsx.
- Start the file with the imports, type definitions, and tabs constant by copying this code:
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'motion/react';
// Shape of event data received from the server
interface EventData {
_id: string;
title: { fr: string; en: string };
description: { fr: string; en: string };
date: string;
time: string;
location: { fr: string; en: string };
category?: string;
}
interface EventListProps {
events: EventData[];
locale: string;
}
// Tab options for filtering events by date
const tabs = ['upcoming', 'past'] as const;
What do these types define?
The EventData interface mirrors the shape of a serialized event from MongoDB. Bilingual fields like title and description have fr and en sub-fields, just like your BlogPost model.
The tabs constant is defined outside the component so it doesn't get recreated on every render. The as const tells TypeScript these values are fixed strings, not generic string[].
- Below the tabs constant, add the component function with state, filtering logic, and tab button rendering:
export default function EventList({ events, locale }: EventListProps) {
const t = useTranslations('EventsPage');
const lang = locale as 'fr' | 'en';
// Track which tab is active (upcoming by default)
const [active, setActive] = useState<string>('upcoming');
const now = new Date().toISOString();
// Filter events: compare ISO date strings
const filtered = events.filter(e =>
active === 'upcoming' ? e.date >= now : e.date < now
);
return (
<section className="px-4 md:px-16 py-16">
<h1 className="text-4xl md:text-[56px] font-extrabold leading-tight mb-4" style={{ fontFamily: 'Plus Jakarta Sans' }}>
{t('heading')}
</h1>
<p className="text-lg text-[#4d4353] mb-8" style={{ fontFamily: 'Be Vietnam Pro' }}>{t('description')}</p>
<div className="flex flex-wrap gap-3 mb-8">
{tabs.map(tab => (
<button key={tab} onClick={() => setActive(tab)} className={`px-5 py-2 rounded-full text-sm font-semibold transition-colors ${active === tab ? 'bg-[#8529c6] text-white' : 'bg-[#f3ede6] text-[#4d4353] border border-[#d0c2d5]'}`}>
{t(`tab${tab.charAt(0).toUpperCase() + tab.slice(1)}` as any)}
</button>
))}
</div>
<div className="flex flex-col gap-6 max-w-3xl">
</div>
</section>
);
}
How does the date filtering work?
The now variable captures today's date as an ISO string (like 2026-08-23T...). ISO strings sort chronologically as plain text, so comparing e.date >= now correctly identifies future events.
The tab buttons dynamically build their translation key from the tab name. For 'upcoming', it looks up tabUpcoming from your translation files. The active tab gets the purple background to match the Gallery filter style from earlier projects.
- Save components/EventList.tsx.
Create the Events page server component
The Events page follows the same pattern as the Blog page: an async Server Component connects to MongoDB, fetches the data, serializes it, and hands it to a client component for rendering.
- Create a new file at app/[locale]/evenements/page.tsx.
- Add the full page component by copying this code:
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import { dbConnect } from '@/lib/dbConnect';
import { Event } from '@/models/Event';
import EventList from '@/components/EventList';
// Bilingual SEO metadata using translation keys
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'EventsPage' });
return { title: t('title'), description: t('description') };
}
export default async function EventsPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
await dbConnect();
// Fetch all published events, sorted by date (earliest first)
const events = await Event.find({ published: true }).sort({ date: 1 }).lean();
// Serialize MongoDB objects for the client component
const serialized = events.map(e => ({ ...e, _id: String(e._id), date: e.date.toISOString() }));
return (
<>
<EventList events={serialized} locale={locale} />
</>
);
}
What does this page do?
This Server Component handles the MongoDB query on the server, then passes plain JavaScript objects to EventList. The .sort({ date: 1 }) sorts events chronologically (earliest first). The .lean() call returns plain objects instead of full Mongoose documents, which is all the client component needs.
Serialization converts _id to a string and date to an ISO string. Without this, Next.js would throw an error because MongoDB ObjectIds and Date objects can't be sent from server to client.
- Save app/[locale]/evenements/page.tsx.
- Make sure your dev server is running. If it's not, start it by running:
npm run dev
- Navigate to http://localhost:3000/fr/evenements in your browser.
You should see the Events page with the heading "Événements", a description, and two tab buttons ("À venir" and "Passé"). The event cards area is empty because you haven't added the card rendering yet.
Page not loading?
- Check that your file is at exactly app/[locale]/evenements/page.tsx. The folder name must be evenements (no accent on the first 'e').
- Make sure your EventsPage namespace is valid JSON in both translation files. A missing comma or bracket breaks the entire file.
- Confirm MongoDB is still running locally.
Why isn't my Events page loading at /fr/evenements?
The page renders with heading and tabs. Now let's fill in the event cards.
- Return to components/EventList.tsx.
- Find the empty <div className="flex flex-col gap-6 max-w-3xl"> element.
- Add the event cards rendering inside that div by copying this code between the opening and closing tags:
{filtered.length === 0 && (
<p className="text-[#7f7384]" style={{ fontFamily: 'Be Vietnam Pro' }}>{t('noEvents')}</p>
)}
{filtered.map((event, i) => {
const formattedDate = new Date(event.date).toLocaleDateString(
locale === 'fr' ? 'fr-FR' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric' }
);
return (
<motion.div key={event._id} initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.15, duration: 0.5 }} viewport={{ once: true }} className="rounded-[3rem] p-8 bg-[#f9f3ec] border border-[#d0c2d5]">
<div className="flex items-start justify-between mb-4">
<span className="text-sm font-semibold text-[#8529c6]">{formattedDate} - {event.time}</span>
</div>
<h3 className="text-xl font-bold mb-2" style={{ fontFamily: 'Plus Jakarta Sans' }}>{event.title[lang]}</h3>
<p className="text-[#4d4353] mb-2" style={{ fontFamily: 'Be Vietnam Pro' }}>{event.description[lang]}</p>
<p className="text-sm text-[#7f7384] mb-4">{event.location[lang]}</p>
{active === 'upcoming' && <p className="text-sm text-[#8529c6] font-semibold">{t('rsvpButton')}</p>}
</motion.div>
);
})}
What does this code do?
- If no events match the active filter, a "no events" message shows using the noEvents translation key.
- Each event card formats the date using toLocaleDateString with the active locale, so French users see "23 août 2026" while English users see "August 23, 2026".
- The motion.div wrapper adds scroll-triggered fade-in animations that match the style your blog cards already use.
- The RSVP text only appears on upcoming events. Past events don't need RSVPs. For now it's just a text label. You'll turn it into a working form in the next step.
- Save components/EventList.tsx.
Before you refresh the page, think about this: your seed data has 2 upcoming events and 2 past events. The default tab is "À venir" (upcoming). How many event cards do you expect to see?
- Refresh your browser at http://localhost:3000/fr/evenements.
You should see 2 event cards on the "À venir" tab, each showing a formatted date, time, location, bilingual title, description, and an "S'inscrire" label.
- Click the Passé tab.
The cards switch to show only the 2 past events. The "S'inscrire" label is gone because past events don't offer RSVPs.
- Click the À venir tab to switch back.
- Change the URL to http://localhost:3000/en/evenements to confirm all text switches to English ("Upcoming", "Past", English titles and descriptions).
Seeing zero events on both tabs?
- Confirm your seed data is in MongoDB. Run mongosh lescaramboles --eval 'db.events.countDocuments()' in a terminal. You should see 4.
- Check that the page uses Event.find({ published: true }) and your seed data has published: true on each event.
- If the tab buttons render but no cards show, check the date filter. If your seed data dates are too close to today, they might all fall on one tab.
Why are my event cards not showing up on the Events page?
✔️ Awesome, I've got everything!
Great. Double check you've saved both components/EventList.tsx and app/[locale]/evenements/page.tsx.
ⓧ I'd like to double check the full code
Here is the full app/[locale]/evenements/page.tsx:
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import { dbConnect } from '@/lib/dbConnect';
import { Event } from '@/models/Event';
import EventList from '@/components/EventList';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'EventsPage' });
return { title: t('title'), description: t('description') };
}
export default async function EventsPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
await dbConnect();
const events = await Event.find({ published: true }).sort({ date: 1 }).lean();
const serialized = events.map(e => ({ ...e, _id: String(e._id), date: e.date.toISOString() }));
return (
<>
<EventList events={serialized} locale={locale} />
</>
);
}
For components/EventList.tsx, confirm your file has these three sections in order:
- The imports, EventData interface, EventListProps interface, and tabs constant (the first code block you added).
- The EventList function with state, filtering, heading, and tab buttons (the second code block).
- Inside the <div className="flex flex-col gap-6 max-w-3xl">, the empty-state message and filtered.map rendering each event card with motion.div (the third code block).
Parents can now see what's happening at Les Caramboles and filter between upcoming and past events. But they can only look. Next up, you'll add an RSVP form so parents can actually sign up for events, powered by a server action that writes to MongoDB.
Add RSVP with a Server Action
Your Events page now shows school events with date-based filtering. Parents can browse upcoming and past events with a single click.
The missing piece is interaction. Right now, the RSVP button on each event card is just static text. In this step, you'll build a server action that validates parent input, saves their RSVP to MongoDB, and gives instant feedback right on the page.
In this step, get ready to:
- Create a server action that validates and saves RSVPs to MongoDB.
- Build an RSVP form component with success and error states.
- Connect the form to event cards so parents can register.
Create the RSVP server action
Server actions let you run server-side code directly from a client component without building a separate API route. Your RSVP action will check that the parent filled in both fields, validate the email format, and then save the data to MongoDB.
- Create a new folder app/actions/ in your project.
- Inside it, create a new file called rsvp.ts and paste the following code:
'use server';
import { dbConnect } from '@/lib/dbConnect';
import { Rsvp } from '@/models/Rsvp';
// Validate and save an RSVP for a school event
export async function submitRsvp(eventId: string, name: string, email: string) {
// Check that both fields are filled in
if (!name || !email) {
return { success: false, error: 'missingFields' };
}
// Validate email format with a basic regex
if (!/^[^@]+@[^@]+\.[^@]+$/.test(email)) {
return { success: false, error: 'invalidEmail' };
}
// Connect to MongoDB and create the RSVP document
await dbConnect();
await Rsvp.create({ eventId, name, email });
return { success: true };
}
What does this code do?
The 'use server' directive at the top tells Next.js this file only runs on the server. Client components can call its exported functions, but the code itself never reaches the browser.
- submitRsvp takes three arguments: the event's ID, the parent's name, and their email.
- It returns an object with success: true on success, or success: false with an error key that maps to a translation string in your JSON files.
- Rsvp.create() saves a new document to the rsvps collection. The createdAt field fills in automatically from the schema default.
Why return error keys instead of messages?
The server action returns 'missingFields' or 'invalidEmail' instead of human-readable messages. The client component uses these keys to look up the translated message in the current locale. This keeps validation bilingual without any extra work on the server.
- Save app/actions/rsvp.ts.
✔️ Awesome, I've got everything!
Great. The form component you build next will call this server action.
ⓧ I'd like to double check the full code
'use server';
import { dbConnect } from '@/lib/dbConnect';
import { Rsvp } from '@/models/Rsvp';
export async function submitRsvp(eventId: string, name: string, email: string) {
if (!name || !email) {
return { success: false, error: 'missingFields' };
}
if (!/^[^@]+@[^@]+\.[^@]+$/.test(email)) {
return { success: false, error: 'invalidEmail' };
}
await dbConnect();
await Rsvp.create({ eventId, name, email });
return { success: true };
}
Build the RSVP form component
The form needs to track what the parent types, call the server action when they submit, and swap between three visual states: the form itself, a success confirmation, or an error message.
- Create a new file components/RsvpForm.tsx and paste the component setup with state and the submit handler:
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { submitRsvp } from '@/app/actions/rsvp';
interface RsvpFormProps {
eventId: string;
}
export default function RsvpForm({ eventId }: RsvpFormProps) {
const t = useTranslations('EventsPage');
// Track form fields and submission state
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
// Call the server action and update status
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const result = await submitRsvp(eventId, name, email);
if (result.success) {
setStatus('success');
setName('');
setEmail('');
} else {
setStatus('error');
setErrorMsg(t(result.error as any));
}
}
What does this code do?
- status tracks whether the form is idle, submitted successfully, or hit an error. This drives which UI the component renders.
- handleSubmit calls the submitRsvp server action you just created. On success, it flips the status and clears the fields. On failure, it looks up the translated error message using the error key.
- Below the handleSubmit function's closing brace, add the JSX return that renders the form and the success state:
// Show confirmation after successful RSVP
if (status === 'success') {
return <p className="text-[#95CC47] font-semibold">{t('rsvpSuccess')}</p>;
}
// Render the RSVP form with name, email, and submit button
return (
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3 mt-4">
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder={t('rsvpNamePlaceholder')} className="flex-1 px-4 py-2 rounded-full border-2 border-[#d0c2d5] focus:border-[#8529c6] outline-none bg-white text-sm" />
<input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder={t('rsvpEmailPlaceholder')} className="flex-1 px-4 py-2 rounded-full border-2 border-[#d0c2d5] focus:border-[#8529c6] outline-none bg-white text-sm" />
<button type="submit" className="px-6 py-2 rounded-full bg-[#feae3f] text-[#6d4400] font-semibold text-sm">{t('rsvpButton')}</button>
{status === 'error' && <p className="text-red-500 text-sm mt-1">{errorMsg}</p>}
</form>
);
}
What happens on success vs. error?
- When status is 'success', the entire form disappears and a green confirmation message takes its place.
- When status is 'error', the form stays visible and a red error message appears below it so the parent can fix their input and try again.
- Save components/RsvpForm.tsx.
✔️ Awesome, I've got everything!
Great. Make sure both code chunks are in the same file, one after the other.
ⓧ I'd like to double check the full code
Scroll up and confirm your components/RsvpForm.tsx has these sections in order:
- The four imports at the top, including the server action import.
- The RsvpFormProps interface with eventId.
- The four useState declarations inside the component.
- The handleSubmit function that calls submitRsvp.
- The success check returning a <p> element, followed by the form JSX with two inputs and a submit button.
Connect the form to event cards
The form component is ready, but it is not rendered anywhere yet. You need to import it into EventList.tsx and swap out the placeholder text for the real form.
- In components/EventList.tsx, add this import at the top of the file, below the existing motion import:
import RsvpForm from '@/components/RsvpForm';
- Scroll down to the bottom of the component and find this line:
{active === 'upcoming' && <p className="text-sm text-[#8529c6] font-semibold">{t('rsvpButton')}</p>}
- Replace that entire line with the RsvpForm component, passing the event's ID:
{active === 'upcoming' && <RsvpForm eventId={event._id} />}
- Save components/EventList.tsx.
- Refresh your browser and navigate to the Events page. Each upcoming event card now shows two input fields (name and email) and a yellow RSVP button.
Don't see the form on event cards?
- Make sure you are on the Upcoming tab. The form only renders for upcoming events, not past ones.
- Check that the import path in EventList.tsx matches exactly: @/components/RsvpForm. A typo in the path causes a module-not-found error in the terminal.
- Confirm your seeded events include at least one event with a date in the future. If all 4 events have past dates, no upcoming cards will appear.
Why isn't my RSVP form showing on event cards?
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
Your components/EventList.tsx is too long to show in one block, so scroll through your file and confirm:
- The imports section at the top includes import RsvpForm from '@/components/RsvpForm'; after the Motion import.
- Near the bottom of the component, the old <p> placeholder is replaced with <RsvpForm eventId={event._id} />.
- Everything else in the file is unchanged from the previous step.
Test the full RSVP flow
The form is wired up. Before you submit an RSVP, think about what you expect to happen. The form calls a server action that validates input and saves to MongoDB. What should you see if you enter a valid name and email?
- On the Events page, find an upcoming event card.
- Type a name into the first input field.
- Type an email address into the second input field.
- Click the RSVP button.
The form disappears and a green confirmation message takes its place. That swap is the success state you coded in RsvpForm.tsx.
Now confirm the data actually reached MongoDB. Run this command in a separate terminal:
- Verify the RSVP was saved to the database by running:
mongosh lescaramboles --eval 'db.rsvps.find().pretty()'
You should see your RSVP document with the event's ObjectId, your name, email, and a createdAt timestamp.
Seeing an error when you submit?
- Check your terminal for a Mongoose connection error. Make sure MongoDB is still running locally and that .env.local has the correct MONGODB_URI.
- If the form does nothing on click, open your browser's developer console and look for errors. A missing or misspelled import in RsvpForm.tsx shows up here.
- If mongosh shows an empty result, double check that you typed a name and email before clicking submit. Submitting empty fields returns the missingFields error instead of saving.
Why is my RSVP not saving to MongoDB?
The school website now has a working RSVP system. Parents can register for events directly on the page, and every submission is validated and stored in MongoDB. Next up, take on an optional Secret Mission to add related posts to your blog detail pages.
Secret mission
Add Related Posts to the Blog Detail Page
Right now, a blog post is a dead end. Readers finish the article and have nowhere to go next. In this Secret Mission, you'll add a "Related Posts" section that queries MongoDB for posts in the same category (excluding the current one) and renders them as clickable cards below the article.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- MongoDB events collection (4 seeded event documents).
- MongoDB rsvps collection (RSVP submission documents).
- New files added to les-caramboles-frontend (models, components, pages, server action, and translation updates).
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with the next project in the series.
- Your MongoDB collections (events, rsvps) stay in place and are ready to query.
- All new files in les-caramboles-frontend remain on disk.
- If your Next.js dev server is still running, it continues serving the site at localhost:3000.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and database stay intact.
- If your Next.js dev server is running, switch to its terminal and press Ctrl+C to stop it.
- MongoDB can keep running in the background with minimal resource use. To stop it, follow the shutdown instructions for your operating system from the MongoDB docs.
- Your project files and seeded data will be exactly where you left them when you come back.
Delete - I don't want to use this again
Remove the MongoDB collections and project files created in this project. Only follow these steps if you do not plan to continue with the Les Caramboles series.
- Drop the events collection by running:
mongosh lescaramboles --eval 'db.events.drop()'
- Drop the rsvps collection by running:
mongosh lescaramboles --eval 'db.rsvps.drop()'
Both commands should print true to confirm the collection was dropped.
- To remove the new files this project added, delete the following from your les-caramboles-frontend folder:
- If you want to delete the entire project (including all work from previous projects in the series), run:
cd ~/Desktop
rm -rf les-caramboles-frontend
On Windows, use this command instead:
cd ~\Desktop
rmdir /s /q les-caramboles-frontend
Nice Work!
Nice Work!
Nice work! You just turned the Les Caramboles website from a collection of static pages into a fully connected school platform.
Blog cards now lead to full articles, parents can browse upcoming events, and RSVPs flow straight into MongoDB. That is a real full-stack loop.
You've learned how to:
- Build dynamic route segments with [slug] that load bilingual blog articles from MongoDB using findOne({ slug }) and return a proper 404 with notFound() when a slug does not exist.
- Create an Events page that fetches school events from Mongoose, splits them into Upcoming and Past tabs using client-side date comparison, and displays bilingual content with scroll animations.
- Wire up a complete RSVP system using a server action that validates input, saves RSVPs to MongoDB with Rsvp.create(), and swaps the form for a confirmation message on success.
- Completed a Secret Mission to add a Related Posts section to blog detail pages using MongoDB's $ne operator to exclude the current post and surface other articles from the same category.
Ready to quiz yourself?