Extend the Les Caramboles Website

Add four bilingual pages to a school website powered by MongoDB.

Introduction

30 Second Summary

Every school has a story beyond its front door. Parents want to explore the curriculum, browse photos of daily life, and catch up on the latest news before choosing where to send their child.

In this project, you will extend the Les Caramboles bilingual school website from a single homepage into a complete six-page site. You'll build four new pages, seed dynamic content into MongoDB for blog posts and gallery items, and add scroll animations that bring the pages to life.

What You'll Build

Toggle the language on any page of the finished site and watch every heading, description, and article switch between French and English in real time.

By the end of this project, you'll have:

  • A bilingual toggle that switches every page between French and English with one click, powered by JSON translation files for page content and MongoDB for dynamic blog and gallery data.
  • About and Curriculum pages where animated pillar cards fade into view as you scroll, program cards show live data with age badges, and a visual daily schedule maps out the school day.
  • Gallery and Blog pages where category tabs instantly filter school photos and article cards display thumbnails, dates, and excerpts in a responsive grid.
  • Secret Mission: an optional challenge to extend the site even further.

Do I need to complete Project 1 first?

Yes. This project picks up where Project 1 (Build a Bilingual School Homepage) left off. You need the les-caramboles-frontend project with its homepage, layout shell, seeded programs in MongoDB, and contact page already in place.

Before We Start

Before we dive in, let's lock in what you're extending and why it matters.

Taking a moment to articulate your goal now keeps you focused through every step ahead.

Fix MongoDB Translations and Seed Content Data

Create Content Models and Seed Blog and Gallery Data

Your Project 1 site already loads translations from JSON files in the messages/ folder. Those files already contain every namespace for every page, so the translation pipeline is ready to go.

The new pages you are building need dynamic content that changes over time: blog posts and gallery photos. In this step, you will create Mongoose models for that dynamic data and seed MongoDB with sample blog posts and gallery items.

In this step, get ready to:
  • Create Mongoose models for blog posts and gallery items.
  • Seed MongoDB with sample blog posts and gallery items.
Create the BlogPost and GalleryItem models

The blog and gallery pages need their own data shapes in the database. You will create two Mongoose schemas that define how blog posts and gallery items are stored.

  • Create models/BlogPost.ts with the blog post schema by pasting this code:
import mongoose, { Schema, model, models } from 'mongoose';

// Bilingual blog post with fr/en fields for title, excerpt, and content
const BlogPostSchema = new Schema({
  slug: { type: String, required: true, unique: true },
  title: { fr: { type: String, required: true }, en: { type: String, required: true } },
  excerpt: { fr: { type: String, required: true }, en: { type: String, required: true } },
  content: { fr: { type: String, required: true }, en: { type: String, required: true } },
  date: { type: Date, required: true },
  imageUrl: { type: String },
  category: { type: String },
  published: { type: Boolean, default: true }
});

export const BlogPost = models.BlogPost ?? model('BlogPost', BlogPostSchema);

Why bilingual fields instead of a translation key?

Blog posts have unique content per article, not reusable UI labels. Storing title.fr and title.en directly on each post lets the page component pick the right language at render time without a second database call.

  • Save BlogPost.ts.
  • Create models/GalleryItem.ts with the gallery item schema by pasting this code:
import mongoose, { Schema, model, models } from 'mongoose';

// Gallery photo metadata with category filter and bilingual alt/caption
const GalleryItemSchema = new Schema({
  imageUrl: { type: String, required: true },
  category: { type: String, enum: ['Nature', 'Art', 'Discovery'], required: true },
  alt: { fr: { type: String, required: true }, en: { type: String, required: true } },
  caption: { fr: { type: String }, en: { type: String } },
  featured: { type: Boolean, default: false },
  order: { type: Number, default: 0 }
});

export const GalleryItem = models.GalleryItem ?? model('GalleryItem', GalleryItemSchema);

What does this schema store?

Each gallery item stores a reference to an image in public/gallery/, a category enum that the filter tabs will use (Nature, Art, or Discovery), bilingual alt text for accessibility, and an order field to control display sequence.

  • Save GalleryItem.ts, then confirm both new model files exist by running:
ls models/

You should see BlogPost.ts, GalleryItem.ts, and Program.ts listed.

Missing a model file?

Make sure you created each file inside the models/ folder, not in the project root. The file names are case-sensitive.

help me find my missing model files in les-caramboles-frontend

✔️ Awesome, I've got everything!

Great. Make sure you have saved both files before moving on.

ⓧ I'd like to double check the full code

Scroll up and compare each model file against its code block above. Confirm that:

  • BlogPost.ts has bilingual title, excerpt, content fields plus slug, date, and published.
  • GalleryItem.ts has category with the enum ['Nature', 'Art', 'Discovery'] and bilingual alt and caption.
Seed blog and gallery data into MongoDB

Models define the shape of your data. Now you need to fill the database with the actual blog posts and gallery items your pages will display.

  • Open lib/seed.ts in VS Code. At the top, add these two imports below the existing import lines:
// Add below existing imports at the top of lib/seed.ts
import { BlogPost } from '../models/BlogPost';
import { GalleryItem } from '../models/GalleryItem';
  • Above the seed() function, add the blog post and gallery item data. Then add the seeding calls inside the seed() function after the existing Program seeding:
// Add above the seed() function
const blogPosts = [
  { slug: 'journee-nature', title: { fr: 'Journee Nature en Foret', en: 'Nature Day in the Forest' }, excerpt: { fr: 'Les enfants ont explore la mangrove de Martinique.', en: 'The children explored the Martinique mangrove.' }, content: { fr: 'Article complet sur la sortie nature.', en: 'Full article about the nature trip.' }, date: new Date('2025-03-15'), imageUrl: '/blog/nature-day.jpg', category: 'Nature', published: true },
  { slug: 'exposition-art', title: { fr: 'Exposition Art des Eleves', en: 'Student Art Exhibition' }, excerpt: { fr: 'Decouvrez les oeuvres de nos artistes.', en: 'Discover the artworks of our students.' }, content: { fr: 'Retour sur notre exposition annuelle.', en: 'A look at our annual art exhibition.' }, date: new Date('2025-02-20'), imageUrl: '/blog/art-expo.jpg', category: 'Art', published: true },
  { slug: 'atelier-volcans', title: { fr: 'Atelier Sciences : Les Volcans', en: 'Science Workshop: Volcanoes' }, excerpt: { fr: 'Nos petits scientifiques en action.', en: 'Our little scientists in action.' }, content: { fr: 'Retour sur notre atelier volcans.', en: 'A recap of our volcano workshop.' }, date: new Date('2025-01-10'), imageUrl: '/blog/volcano.jpg', category: 'Science', published: true },
  { slug: 'rentree-2025', title: { fr: 'Rentree Scolaire 2025', en: 'Back to School 2025' }, excerpt: { fr: 'Tout savoir pour la rentree.', en: 'Everything for back to school.' }, content: { fr: 'Guide de rentree pour les parents.', en: 'Back to school guide for parents.' }, date: new Date('2025-08-25'), imageUrl: '/blog/rentree.jpg', category: 'General', published: true }
];

const galleryItems = [
  { imageUrl: '/gallery/nature-1.jpg', category: 'Nature', alt: { fr: 'Jardin potager', en: 'Vegetable garden' }, caption: { fr: 'Notre jardin', en: 'Our garden' }, featured: true, order: 1 },
  { imageUrl: '/gallery/nature-2.jpg', category: 'Nature', alt: { fr: 'Sortie en foret tropicale', en: 'Tropical forest trip' }, caption: { fr: 'Exploration nature', en: 'Nature exploration' }, featured: false, order: 2 },
  { imageUrl: '/gallery/art-1.jpg', category: 'Art', alt: { fr: 'Peinture collective', en: 'Group painting' }, caption: { fr: 'Art collectif', en: 'Group art' }, featured: true, order: 3 },
  { imageUrl: '/gallery/art-2.jpg', category: 'Art', alt: { fr: 'Modelage en argile', en: 'Clay modeling' }, caption: { fr: 'Atelier argile', en: 'Clay workshop' }, featured: false, order: 4 },
  { imageUrl: '/gallery/discovery-1.jpg', category: 'Discovery', alt: { fr: 'Experience scientifique', en: 'Science experiment' }, caption: { fr: 'Sciences en action', en: 'Science in action' }, featured: true, order: 5 },
  { imageUrl: '/gallery/discovery-2.jpg', category: 'Discovery', alt: { fr: 'Observation des insectes', en: 'Bug observation' }, caption: { fr: 'Petits explorateurs', en: 'Little explorers' }, featured: false, order: 6 }
];

// Add inside the seed() function, after the existing Program seeding
await BlogPost.deleteMany({});
await BlogPost.insertMany(blogPosts);
console.log('Seeded 4 blog posts');
await GalleryItem.deleteMany({});
await GalleryItem.insertMany(galleryItems);
console.log('Seeded 6 gallery items');

What do the seeding calls do?

Each deleteMany({}) clears the collection first so you can re-run the script without duplicates. Then insertMany() writes all documents in one batch. BlogPost gets 4 sample articles. GalleryItem gets 6 photos spread across the three categories.

  • Save lib/seed.ts.
  • Run the seed script by running:
npx tsx lib/seed.ts

You should see output confirming the seeded counts, including the existing programs from Project 1 plus the two new collections.

Seeing a connection error?

Make sure your local MongoDB instance is running. Check that .env.local still has MONGODB_URI=mongodb://127.0.0.1:27017/lescaramboles from Project 1.

If you see a validation error, double-check that the blog post and gallery item objects match the schema fields exactly. Every required field must be present.

help me debug my seed script connection or validation error

Your database is loaded with blog posts and gallery items. Your JSON translation files already have all the namespaces each page needs. Next up, you will build the About page where parents learn what makes this school special.

Build the About Page

Your database is seeded with blog posts and gallery items, and your JSON translation files already contain every namespace the site needs. The homepage toggles between French and English with one click.

Parents want to understand what makes this school different before they visit. The About page tells that story. In this step, you will build the first new content page with a philosophy hero, three pillar detail cards, and a team section.

In this step, get ready to:
  • Create the About page route with bilingual SEO metadata.
  • Build the AboutHero component with the school's philosophy headline.
  • Build the PillarDetails and TeamSection components to complete the page.
Create the hero component and About page route

Your JSON translation files already contain the AboutPage namespace with keys for the badge, headline, pillar titles, and team section text. Those translations are ready to use.

A Next.js page route needs at least one component to render. You will build the hero component first, then create the page route that imports it.

  • Create a new file called components/AboutHero.tsx inside your les-caramboles-frontend project and paste the following code:
import { useTranslations } from 'next-intl';

export default function AboutHero() {
  // Load all translation keys from the AboutPage namespace
  const t = useTranslations('AboutPage');
  return (
    <section className="px-4 md:px-16 py-16 text-center">
      {/* Badge pill with secondary-container background */}
      <span className="inline-block px-6 py-2 rounded-full bg-[#feae3f] text-[#6d4400] text-sm font-semibold mb-6">{t('badge')}</span>
      {/* Headline with each pillar word in its accent color */}
      <h1 className="text-4xl md:text-[56px] font-extrabold leading-tight mb-6" style={{ fontFamily: 'Plus Jakarta Sans' }}>
        Growing through <span className="text-[#95CC47]">Nature</span>, <span className="text-[#855300]">Art</span> & <span className="text-[#8529c6]">Discovery</span>
      </h1>
      <p className="text-lg max-w-2xl mx-auto text-[#4d4353]" style={{ fontFamily: 'Be Vietnam Pro' }}>{t('description')}</p>
    </section>
  );
}

What does this component do?

useTranslations('AboutPage') pulls the AboutPage namespace from the JSON translation file that i18n/request.ts loaded at render time. Calling t('badge') returns the French or English badge text depending on the active locale.

The headline colors each pillar word in its Chromatic Discovery accent: nature-green (#95CC47), secondary (#855300), and primary (#8529c6).

💡 Why useTranslations instead of getTranslations?

AboutHero is a synchronous Server Component (no async keyword, no 'use client' directive). In next-intl, synchronous components use useTranslations. Async Server Components and generateMetadata must use getTranslations from next-intl/server instead. Mixing them up causes the use() bug you may have hit in Project 1.

  • Save components/AboutHero.tsx.

The hero component is ready, but it needs a page route to render inside. The page will be an async Server Component so it can use getTranslations for bilingual SEO metadata.

  • Create the folder app/[locale]/about/ inside your les-caramboles-frontend project.
  • Create a new file called page.tsx inside that folder and paste the following code:
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import AboutHero from '@/components/AboutHero';

// generateMetadata runs on the server to set bilingual SEO tags
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  // getTranslations is the async-safe version for server contexts
  const t = await getTranslations({ locale, namespace: 'AboutPage' });
  return { title: t('title'), description: t('description') };
}

export default async function AboutPage() {
  return (
    <>
      <AboutHero />
    </>
  );
}

What does this code do?

  • generateMetadata is an async function that Next.js calls to set the page's <title> and <meta description> tags. It awaits params (a Promise in Next.js 16) to get the current locale, then uses getTranslations to pull bilingual text from your JSON files for the browser tab title.
  • The AboutPage function itself just renders the hero component for now. You will add more components in the next substep.
  • Save app/[locale]/about/page.tsx.
  • Open your browser and navigate to http://localhost:3000/fr/about.

You should see the "Notre Projet Educatif" badge in an orange pill, the "Growing through Nature, Art & Discovery" headline with colored words, and a description paragraph below it.

Seeing a 404 or blank page?

  • Check that the folder path is exactly app/[locale]/about/page.tsx. The [locale] folder must include the square brackets.
  • Make sure your dev server is still running. If it stopped, restart it with npm run dev.
  • Confirm that AboutHero.tsx is saved in the components/ folder, not inside app/.

Help me debug why my About page isn't rendering at /fr/about.

Build the PillarDetails and TeamSection components

The hero sets the tone, but the About page needs more depth. The pillar cards explain the school's three educational pillars: Nature, Art, and Science. The team section introduces the people behind the school.

  • Create a new file called components/PillarDetails.tsx and paste the following code:
'use client';
import { useTranslations } from 'next-intl';

export default function PillarDetails() {
  const t = useTranslations('AboutPage');
  // Each pillar has its own icon, translation keys, and accent color
  const pillars = [
    { icon: '🌿', titleKey: 'pillarNatureTitle', descKey: 'pillarNatureDesc', accent: '#95CC47' },
    { icon: '🎨', titleKey: 'pillarArtTitle', descKey: 'pillarArtDesc', accent: '#feae3f' },
    { icon: '🔬', titleKey: 'pillarScienceTitle', descKey: 'pillarScienceDesc', accent: '#a048e1' }
  ];
  return (
    <section className="px-4 md:px-16 py-16">
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 max-w-5xl mx-auto">
        {pillars.map((p, i) => (
          <div key={p.titleKey} className="rounded-[3rem] p-8 bg-[#f9f3ec] border border-[#d0c2d5]">
            {/* Icon circle with a translucent tint of the pillar's accent color */}
            <div className="w-16 h-16 rounded-full flex items-center justify-center text-3xl mb-6" style={{ backgroundColor: `${p.accent}20` }}>{p.icon}</div>
            <h3 className="text-xl font-bold mb-3" style={{ fontFamily: 'Plus Jakarta Sans' }}>{t(p.titleKey as any)}</h3>
            <p className="text-[#4d4353]" style={{ fontFamily: 'Be Vietnam Pro' }}>{t(p.descKey as any)}</p>
          </div>
        ))}
      </div>
    </section>
  );
}

What does this component do?

  • The pillars array defines three cards: Nature & Ecology (green), Art & Expression (orange), and Scientific Discovery (purple). Each card maps to translation keys like pillarNatureTitle and pillarNatureDesc in the AboutPage namespace.
  • Each icon circle gets a translucent tint of its pillar's accent color by appending 20 (12% opacity) to the hex value. The cards use the Chromatic Discovery design: ultra-rounded corners (3rem), surface-container-low background, and outline-variant border.

💡 Why is PillarDetails a client component?

The 'use client' directive is here because scroll animations will be added to this component in the next step. Motion requires client-side JavaScript to animate elements as they enter the viewport. For now, the cards render statically.

Now add the PillarDetails component to your About page so it renders below the hero.

  • In app/[locale]/about/page.tsx, find this import line:
import AboutHero from '@/components/AboutHero';
  • Add a new import for PillarDetails directly below it, so the imports now look like this:
import AboutHero from '@/components/AboutHero';
import PillarDetails from '@/components/PillarDetails';
  • Then find the return statement and add <PillarDetails /> below <AboutHero />:
  return (
    <>
      <AboutHero />
      <PillarDetails />
    </>
  );
  • Save components/PillarDetails.tsx and app/[locale]/about/page.tsx.
  • Refresh http://localhost:3000/fr/about in your browser.

Below the hero, you should now see three rounded cards in a row: one with a leaf icon (Nature), one with a palette icon (Art), and one with a microscope icon (Science). Each has a title and description in French.

Cards not appearing?

  • Make sure you added <PillarDetails /> inside the <>...</> fragment in the return statement, not outside it.
  • Check that the import path matches the filename exactly: @/components/PillarDetails.

Help me debug why my PillarDetails cards are not showing on the About page.

The last piece is the team section. This is a placeholder for now. The future LMS at lms.mywebsite.com will manage team member photos and bios.

  • Create a new file called components/TeamSection.tsx and paste the following code:
import { useTranslations } from 'next-intl';

export default function TeamSection() {
  const t = useTranslations('AboutPage');
  return (
    <section className="px-4 md:px-16 py-16 text-center">
      <h2 className="text-3xl font-bold mb-4" style={{ fontFamily: 'Plus Jakarta Sans' }}>{t('teamTitle')}</h2>
      <p className="text-lg text-[#4d4353] max-w-2xl mx-auto mb-12" style={{ fontFamily: 'Be Vietnam Pro' }}>{t('teamDescription')}</p>
      {/* Placeholder grid for team member photos (managed by LMS later) */}
      <div className="grid grid-cols-2 md:grid-cols-4 gap-6 max-w-3xl mx-auto">
        {[1, 2, 3, 4].map(i => (
          <div key={i} className="rounded-[3rem] bg-[#f9f3ec] border border-[#d0c2d5] aspect-square flex items-center justify-center">
            <div className="w-12 h-12 rounded-full bg-[#d0c2d5]" />
          </div>
        ))}
      </div>
    </section>
  );
}

What does this component do?

The team section renders a heading and description from the AboutPage translations, followed by a 2x2 (mobile) or 4-column (desktop) grid of placeholder circles. Each placeholder uses the same ultra-rounded card style as the pillar cards.

Wire this final component into the About page.

  • In app/[locale]/about/page.tsx, find this import line:
import PillarDetails from '@/components/PillarDetails';
  • Add a new import for TeamSection directly below it, so the line now reads:
import PillarDetails from '@/components/PillarDetails';
import TeamSection from '@/components/TeamSection';
  • Then update the return statement to include <TeamSection /> below <PillarDetails />:
  return (
    <>
      <AboutHero />
      <PillarDetails />
      <TeamSection />
    </>
  );
  • Save components/TeamSection.tsx and app/[locale]/about/page.tsx.

Before you refresh the page, think about what you expect to see. The hero is already rendering. What should appear below the pillar cards now?

  • Refresh http://localhost:3000/fr/about in your browser.

You should see three sections stacked vertically: the hero with the orange badge and colored headline at the top, three pillar cards in a row below, and a team section with a heading and four placeholder circles at the bottom.

Now test the bilingual toggle. This is the real payoff of your JSON translation setup from Project 1.

  • Navigate to http://localhost:3000/en/about (or use your language toggle).

Every piece of text on the page should update to English. The badge, pillar titles, pillar descriptions, and team section text all switch because they pull from the en.json translation file. Check the browser tab title too. It should show the English page title from generateMetadata.

English text not updating?

  • Confirm that the URL reads /en/about, not /fr/about. The locale in the URL determines which JSON translation file is loaded.
  • Make sure the AboutPage namespace exists in both messages/fr.json and messages/en.json with matching keys.

Help me debug why my About page text does not change when I switch locales.

✔️ Awesome, I've got everything!

Double check that all four files are saved. You are ready for the next step.

ⓧ I'd like to double check the full code

Here is the final code for each file created in this step. Compare yours line by line.

import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import AboutHero from '@/components/AboutHero';
import PillarDetails from '@/components/PillarDetails';
import TeamSection from '@/components/TeamSection';

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'AboutPage' });
  return { title: t('title'), description: t('description') };
}

export default async function AboutPage() {
  return (
    <>
      <AboutHero />
      <PillarDetails />
      <TeamSection />
    </>
  );
}
import { useTranslations } from 'next-intl';

export default function AboutHero() {
  const t = useTranslations('AboutPage');
  return (
    <section className="px-4 md:px-16 py-16 text-center">
      <span className="inline-block px-6 py-2 rounded-full bg-[#feae3f] text-[#6d4400] text-sm font-semibold mb-6">{t('badge')}</span>
      <h1 className="text-4xl md:text-[56px] font-extrabold leading-tight mb-6" style={{ fontFamily: 'Plus Jakarta Sans' }}>
        Growing through <span className="text-[#95CC47]">Nature</span>, <span className="text-[#855300]">Art</span> & <span className="text-[#8529c6]">Discovery</span>
      </h1>
      <p className="text-lg max-w-2xl mx-auto text-[#4d4353]" style={{ fontFamily: 'Be Vietnam Pro' }}>{t('description')}</p>
    </section>
  );
}
'use client';
import { useTranslations } from 'next-intl';

export default function PillarDetails() {
  const t = useTranslations('AboutPage');
  const pillars = [
    { icon: '🌿', titleKey: 'pillarNatureTitle', descKey: 'pillarNatureDesc', accent: '#95CC47' },
    { icon: '🎨', titleKey: 'pillarArtTitle', descKey: 'pillarArtDesc', accent: '#feae3f' },
    { icon: '🔬', titleKey: 'pillarScienceTitle', descKey: 'pillarScienceDesc', accent: '#a048e1' }
  ];
  return (
    <section className="px-4 md:px-16 py-16">
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 max-w-5xl mx-auto">
        {pillars.map((p, i) => (
          <div key={p.titleKey} className="rounded-[3rem] p-8 bg-[#f9f3ec] border border-[#d0c2d5]">
            <div className="w-16 h-16 rounded-full flex items-center justify-center text-3xl mb-6" style={{ backgroundColor: `${p.accent}20` }}>{p.icon}</div>
            <h3 className="text-xl font-bold mb-3" style={{ fontFamily: 'Plus Jakarta Sans' }}>{t(p.titleKey as any)}</h3>
            <p className="text-[#4d4353]" style={{ fontFamily: 'Be Vietnam Pro' }}>{t(p.descKey as any)}</p>
          </div>
        ))}
      </div>
    </section>
  );
}
import { useTranslations } from 'next-intl';

export default function TeamSection() {
  const t = useTranslations('AboutPage');
  return (
    <section className="px-4 md:px-16 py-16 text-center">
      <h2 className="text-3xl font-bold mb-4" style={{ fontFamily: 'Plus Jakarta Sans' }}>{t('teamTitle')}</h2>
      <p className="text-lg text-[#4d4353] max-w-2xl mx-auto mb-12" style={{ fontFamily: 'Be Vietnam Pro' }}>{t('teamDescription')}</p>
      <div className="grid grid-cols-2 md:grid-cols-4 gap-6 max-w-3xl mx-auto">
        {[1, 2, 3, 4].map(i => (
          <div key={i} className="rounded-[3rem] bg-[#f9f3ec] border border-[#d0c2d5] aspect-square flex items-center justify-center">
            <div className="w-12 h-12 rounded-full bg-[#d0c2d5]" />
          </div>
        ))}
      </div>
    </section>
  );
}

The About page is live with three sections that all pull bilingual text from your JSON translation files. The pillar cards appear instantly right now. In the next step, you will add Motion scroll animations that make them fade up as you scroll, and build the Curriculum page with live program data from the database.

Build the Curriculum Page with Scroll Animations

Your About page now renders a full philosophy section with pillar cards and a team area. The next page parents care about is the Curriculum. They want to see which programs the school offers, what age group each serves, and what a typical day looks like.

There is one problem: every card on the About page appears instantly with zero visual feedback as you scroll. This step builds the Curriculum page with live program data from MongoDB and introduces Motion scroll animations that make both the Curriculum and About pages feel alive.

In this step, get ready to:
  • Build a Curriculum page that fetches program data from MongoDB and renders color-coded cards.
  • Create a daily schedule component with time slots and activity descriptions.
  • Add Motion scroll animations to both the Curriculum and About pages.
Build the Curriculum page with program cards from MongoDB

The Curriculum page needs to display real program data. Your Program model from Project 1 already stores Maternelle, Primaire, and Micro Creche with bilingual names, descriptions, age ranges, and tags. The Curriculum page will fetch these directly from MongoDB as a Server Component and pass them to a client component for rendering.

  • Create a new file components/ProgramCards.tsx in VS Code. Start by pasting the imports, color map, and type interface:
'use client';
import { useTranslations } from 'next-intl';
import { motion } from 'motion/react';

// Map each program name to its accent color
const accentColors: Record<string, string> = {
  'Maternelle': '#a048e1',
  'Primaire': '#feae3f',
  'Micro Creche': '#cf4345',
  'Micro Nursery': '#cf4345',
  'Kindergarten': '#a048e1',
  'Primary School': '#feae3f'
};

// Define the shape of program data passed from the server
interface ProgramProps {
  programs: Array<{
    _id: string;
    name: { fr: string; en: string };
    description: { fr: string; en: string };
    ageRange: string;
    classLevels: string[];
    tags?: string[];
  }>;
  locale: string;
}

What does this code do?

The 'use client' directive makes this a client component because it will use Motion animations. The accentColors map assigns a distinct color to each program: purple for Maternelle, orange for Primaire, and pink for Micro Creche.

The ProgramProps interface describes the serialized program data that the server page will pass down. Both French (fr) and English (en) keys are included for bilingual rendering.

  • Now paste the component function below the interface in the same file. This renders the hero heading and the program card grid:
export default function ProgramCards({ programs, locale }: ProgramProps) {
  const t = useTranslations('CurriculumPage');
  const lang = locale as 'fr' | 'en';
  return (
    <section className="px-4 md:px-16 py-16">
      <span className="inline-block px-6 py-2 rounded-full bg-[#feae3f] text-[#6d4400] text-sm font-semibold mb-6">{t('badge')}</span>
      <h1 className="text-4xl md:text-[56px] font-extrabold leading-tight mb-4" style={{ fontFamily: 'Plus Jakarta Sans' }}>
        Growing through <span className="text-[#95CC47]">Nature</span>, <span className="text-[#855300]">Art</span> & <span className="text-[#8529c6]">Discovery</span>
      </h1>
      <p className="text-lg max-w-2xl text-[#4d4353] mb-12" style={{ fontFamily: 'Be Vietnam Pro' }}>{t('description')}</p>
      <div className="flex flex-col gap-8 max-w-3xl mx-auto">
        {programs.map((prog, i) => {
          const accent = accentColors[prog.name.fr] || accentColors[prog.name.en] || '#a048e1';
          return (
            <motion.div key={prog._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">
                <div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: `${accent}20` }}>
                  <div className="w-6 h-6 rounded-full" style={{ backgroundColor: accent }} />
                </div>
                <span className="px-4 py-1 rounded-full bg-[#f2daff] text-[#6d00ad] text-sm font-semibold">{prog.ageRange}</span>
              </div>
              <h3 className="text-xl font-bold mb-2" style={{ fontFamily: 'Plus Jakarta Sans' }}>{prog.name[lang]}</h3>
              <p className="text-[#4d4353] mb-4" style={{ fontFamily: 'Be Vietnam Pro' }}>{prog.description[lang]}</p>
              {prog.tags && (<div className="flex flex-wrap gap-2">
                {prog.tags.map(tag => (<span key={tag} className="px-3 py-1 rounded-full bg-[#e7e2db] text-[#4d4353] text-sm">{tag}</span>))}
              </div>)}
            </motion.div>);
        })}</div>
    </section>);
}

What does this code do?

Each program card gets its accent color from the accentColors map based on the French or English program name. The card displays a colored icon circle, an age badge chip (e.g. "Ages 3-6"), the bilingual name and description, and tag chips at the bottom.

The motion.div wrapper adds scroll animations. Each card starts invisible and shifted down (opacity: 0, y: 30), then fades up into place when it enters the viewport. The staggered delay: i * 0.15 makes each card animate slightly after the previous one.

  • Save ProgramCards.tsx.

The component is ready. Now it needs a page to render it.

  • Create the page route by adding a new file app/[locale]/programme/page.tsx and pasting this code:
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import { dbConnect } from '@/lib/dbConnect';
import { Program } from '@/models/Program';
import ProgramCards from '@/components/ProgramCards';

// Bilingual SEO metadata for the Curriculum page
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'CurriculumPage' });
  return { title: t('title'), description: t('description') };
}

export default async function CurriculumPage({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  // Connect to MongoDB and fetch all programs
  await dbConnect();
  const programs = await Program.find().lean();
  // Serialize MongoDB documents (convert _id to string)
  const serialized = programs.map(p => ({ ...p, _id: String(p._id) }));
  return (
    <>
      <ProgramCards programs={serialized} locale={locale} />
    </>
  );
}

What does this code do?

This is an async Server Component. It calls dbConnect() to open the MongoDB connection, fetches all programs with Program.find().lean(), and serializes the results by converting each _id to a string. Mongoose ObjectIDs cannot be passed directly to client components, so serialization is required.

The generateMetadata function uses getTranslations from next-intl/server to set the browser tab title and description in the current locale.

  • Save app/[locale]/programme/page.tsx.
  • In your browser, navigate to http://localhost:3000/fr/programme.

You should see three program cards (Maternelle, Primaire, and Micro Creche) fade up as the page loads. Each card shows a colored icon circle, an age badge, the program name and description in French, and tag chips at the bottom.

Page not loading or showing errors?

  • If you see a "Module not found" error for ProgramCards, confirm the file is saved at components/ProgramCards.tsx (not inside a subfolder).
  • If cards appear but with no text, your CurriculumPage namespace may be missing from the seeded translations. Re-run npx tsx lib/seed.ts and restart the dev server.
  • Help me debug my Curriculum page
Build the DailySchedule component and add scroll animations

Parents also want to see what a typical day looks like at the school. The daily schedule is a fixed routine for the Micro Creche program, so the data is hardcoded in the component rather than pulled from the database.

  • Create a new file components/DailySchedule.tsx and paste this code:
import { useTranslations } from 'next-intl';

// Fixed daily routine for the Micro Creche program
const schedule = [
  { time: '07h30 - 08h30', activity: 'Accueil des enfants et parent', icon: '\u{1F44B}' },
  { time: '08h30 - 10h00', activity: "Temps d'eveil et de jeux 1 : Decouverte du corps", icon: '\u{1F3B2}' },
  { time: '10h00 - 10h30', activity: "Temps du gouter et d'hygiene", icon: '\u{1F34E}' },
  { time: '10h30 - 12h30', activity: "Temps d'eveil et de jeux 2 : Decouverte du monde", icon: '\u{1F30D}' },
  { time: '12h00 - 14h00', activity: "Temps du repas, d'hygiene et eveil musical", icon: '\u{1F3B5}' },
  { time: '15h30', activity: 'Fin des activites : accueil du soir et sortie.', icon: '\u{1F3E0}' }
];

export default function DailySchedule() {
  const t = useTranslations('CurriculumPage');
  return (
    <section className="px-4 md:px-16 py-16 max-w-3xl mx-auto">
      <h2 className="text-2xl font-bold mb-2" style={{ fontFamily: 'Plus Jakarta Sans' }}>{t('scheduleTitle')}</h2>
      <p className="text-[#4d4353] mb-4" style={{ fontFamily: 'Be Vietnam Pro' }}>{t('scheduleSubtitle')}</p>
      <button className="mb-8 px-6 py-2 rounded-full border-2 border-[#1d1b17] text-sm font-semibold">{t('downloadPdf')}</button>
      <div className="border-t border-[#d0c2d5]">
        {schedule.map((s, i) => (
          <div key={i} className="flex items-start gap-4 py-4 border-b border-[#d0c2d5]">
            <span className="text-sm font-semibold text-[#8529c6] whitespace-nowrap min-w-[80px]">{s.time}</span>
            <span className="text-xl">{s.icon}</span>
            <p className="text-[#4d4353]" style={{ fontFamily: 'Be Vietnam Pro' }}>{s.activity}</p>
          </div>
        ))}
      </div>
    </section>
  );
}

What does this code do?

The schedule array holds six time slots covering a Micro Creche day from 07h30 to 15h30. Each slot has a time range, a French activity description, and an emoji icon.

The component renders a heading, subtitle, and a "Download PDF" button (using translation keys from the CurriculumPage namespace), followed by a table of rows with purple time labels, emoji icons, and activity text.

  • Save DailySchedule.tsx.

The DailySchedule component is ready, but the Curriculum page does not import it yet. You need to wire it in.

  • In app/[locale]/programme/page.tsx, find the import block at the top of the file.
  • Add this import below the ProgramCards import:
import DailySchedule from '@/components/DailySchedule';
  • Now find the return block in the CurriculumPage function. It currently looks like this:
  return (
    <>
      <ProgramCards programs={serialized} locale={locale} />
    </>
  );
  • Add <DailySchedule /> below <ProgramCards /> so the return block looks like this:
  return (
    <>
      <ProgramCards programs={serialized} locale={locale} />
      <DailySchedule />
    </>
  );
  • Save app/[locale]/programme/page.tsx.
  • Refresh http://localhost:3000/fr/programme in your browser.

Below the program cards, you should now see a daily schedule section with a heading, a "Download PDF" button, and six time slots from 07h30 to 15h30. Each row shows a purple time label, an emoji icon, and the activity description.

Don't see the schedule?

  • Check that the import DailySchedule line is at the top of page.tsx and that <DailySchedule /> appears inside the <>...</> fragment.
  • If you see translation key names instead of text (e.g. "scheduleTitle"), confirm your JSON translation files contain the scheduleTitle and downloadPdf keys inside the CurriculumPage namespace in both messages/fr.json and messages/en.json.
  • Help me debug my DailySchedule component

The Curriculum page is looking great. Now go back to the About page at http://localhost:3000/fr/about and scroll down to the pillar cards. Notice how they appear instantly with no animation. The Curriculum cards fade up smoothly, but the About page feels flat in comparison.

The pillar cards use a plain <div> wrapper. By swapping it for a motion.div with the same animation props, you get consistent scroll behavior across both pages.

  • Open components/PillarDetails.tsx.
  • Find this import line at the top:
import { useTranslations } from 'next-intl';
  • Add the Motion import directly below it, so both lines look like this:
import { useTranslations } from 'next-intl';
import { motion } from 'motion/react';
  • Now scroll down to the card rendering inside the pillars.map() call. Find this line:
          <div key={p.titleKey} className="rounded-[3rem] p-8 bg-[#f9f3ec] border border-[#d0c2d5]">
  • Replace that line with a motion.div that includes the same animation props used in ProgramCards:
          <motion.div key={p.titleKey} 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]">
  • Finally, find the matching closing tag further down:
          </div>
  • Replace it with:
          </motion.div>

Why the same animation pattern?

Both ProgramCards and PillarDetails use identical animation values: initial={{ opacity: 0, y: 30 }} to start hidden and shifted down, whileInView={{ opacity: 1, y: 0 }} to fade up when visible, and viewport: { once: true } so the animation only fires once per page load. Consistent animations across pages make the site feel cohesive.

  • Save PillarDetails.tsx.

Before you check the result, what do you expect to see when you visit the About page and scroll down to the pillar cards?

  • Navigate to http://localhost:3000/fr/about and scroll down.

The three pillar cards (Nature, Art, Science) now fade up with a stagger as you scroll, matching the program cards on the Curriculum page.

  • Navigate back to http://localhost:3000/fr/programme for one final check.

Three program cards (Maternelle, Primaire, Micro Creche) should fade up with a stagger. Each shows an age badge, description, and tag chips. Below them, the daily schedule table renders six time slots from 07h30 to 15h30.

  • Switch to http://localhost:3000/en/programme and confirm all program names and descriptions render in English.

Pillar cards not animating?

  • Make sure you changed both the opening <div> to <motion.div> and the closing </div> to </motion.div>. A mismatched pair causes a runtime error.
  • Confirm the import { motion } from 'motion/react' line is present at the top of PillarDetails.tsx. Without it, motion.div is undefined.
  • Help me debug my PillarDetails scroll animation

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Your project now has three files that changed in this step. Scroll up and confirm each chunk is present in order.

In components/ProgramCards.tsx: the 'use client' directive, imports for useTranslations and motion, the accentColors map, the ProgramProps interface, and the ProgramCards function with motion.div cards.

In app/[locale]/programme/page.tsx: imports for getTranslations, dbConnect, Program, ProgramCards, and DailySchedule. The generateMetadata function. The CurriculumPage function that fetches and serializes programs, rendering both <ProgramCards /> and <DailySchedule />.

In components/PillarDetails.tsx: the motion import added below useTranslations, and the card wrapper changed from <div> to <motion.div> with whileInView animation props.

Your Curriculum page is live with animated program cards and a daily schedule. Next up, you will build the Gallery page where parents can browse school photos by category.

Build the Gallery Page with Category Filtering

Your Curriculum page now pulls live program data from MongoDB and animates each card into view as you scroll. The site has pages with real content, but there is still no way for parents to browse the school's photo collection.

The school has photos sitting in the public/gallery/ folder with no page to display them. This step creates a Gallery page that loads photo metadata from MongoDB, displays images in a responsive grid, and lets parents filter by category with a single click.

In this step, get ready to:
  • Create a Gallery page that fetches gallery items from MongoDB.
  • Build a client-side grid component with category filter tabs.
  • Add a Share Your Moments CTA section with an Upload Photo button.
Create the Gallery page with a flat image grid

The GalleryPage translation keys (title, description, category labels) are already in your JSON translation files from Project 1. You can jump straight into building the page route and the grid component.

  • Create a new file at app/[locale]/gallery/page.tsx and add the initial page route by copying this code:
// Server-side imports for database and component
import { dbConnect } from '@/lib/dbConnect';
import { GalleryItem } from '@/models/GalleryItem';
import GalleryGrid from '@/components/GalleryGrid';

export default async function GalleryPage({ params }: { params: Promise<{ locale: string }> }) {
  // Read the current locale from the URL
  const { locale } = await params;

  // Connect to MongoDB and fetch all gallery items sorted by display order
  await dbConnect();
  const items = await GalleryItem.find().sort({ order: 1 }).lean();

  // Convert Mongoose _id to a plain string for client-side use
  const serialized = items.map(item => ({ ...item, _id: String(item._id) }));

  return (
    <>
      <GalleryGrid items={serialized} locale={locale} />
    </>
  );
}

What does this code do?

This Server Component follows the same pattern as the Curriculum page. GalleryItem.find().sort({ order: 1 }).lean() fetches every gallery item from MongoDB sorted by display order.

The serialized step converts each Mongoose _id to a plain string so the data can cross the Server-to-Client boundary.

  • Save app/[locale]/gallery/page.tsx.
  • You should see the gallery/ folder appear under app/[locale]/ in the VS Code Explorer sidebar.

The page needs a GalleryGrid component to render the images. You will build it in two parts: first the type definitions, then the component function.

  • Create a new file at components/GalleryGrid.tsx and start with the imports and type definitions:
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import Image from 'next/image';

// Shape of each gallery item from MongoDB
interface GalleryItemData {
  _id: string;
  imageUrl: string;
  category: string;
  alt: { fr: string; en: string };
  caption?: { fr?: string; en?: string };
  featured: boolean;
}

// Props the parent page passes to this component
interface GalleryGridProps {
  items: GalleryItemData[];
  locale: string;
}

// Filter options matching the GalleryItem model's category enum
const categories = ['all', 'Nature', 'Art', 'Discovery'] as const;

What do these types do?

GalleryItemData mirrors the shape of each document in your MongoDB GalleryItem collection. The category field matches the enum values Nature, Art, and Discovery.

categories is a tuple of filter options. The 'all' entry shows every image regardless of category.

  • Save GalleryGrid.tsx. You should see the file appear in the components/ folder in the VS Code sidebar.
  • Still in GalleryGrid.tsx, add the component function below the categories constant:
export default function GalleryGrid({ items, locale }: GalleryGridProps) {
  const t = useTranslations('GalleryPage');
  const lang = locale as 'fr' | 'en';
  // Track which category filter is active (defaults to showing all)
  const [active, setActive] = useState<string>('all');

  // Filter items by the selected category
  const filtered = active === 'all' ? items : items.filter(item => item.category === active);

  return (
    <section className="px-4 md:px-16 py-16">
      <h1 className="text-4xl md:text-[56px] font-extrabold leading-tight mb-2" style={{ fontFamily: 'Plus Jakarta Sans' }}>
        {t('headlinePre')} <span className="text-[#855300] underline decoration-[#feae3f] decoration-4 underline-offset-4">{t('headlineHighlight')}</span>
      </h1>
      <p className="text-lg text-[#4d4353] mb-8" style={{ fontFamily: 'Be Vietnam Pro' }}>{t('description')}</p>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
        {filtered.map(item => (
          <div key={item._id} className="relative aspect-[4/3] rounded-[1.5rem] overflow-hidden">
            <Image src={item.imageUrl} alt={item.alt[lang]} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" />
          </div>
        ))}
      </div>
    </section>
  );
}

What does this component do?

useState tracks which category is active. It defaults to 'all', so every image shows on first load.

filtered re-derives the visible items whenever active changes. When active is 'all', it returns the full array. Otherwise it keeps only items whose category matches.

The grid uses responsive columns: 1 on mobile, 2 at the sm breakpoint, and 3 at lg. Each image uses next/image with fill to cover its container while keeping the aspect ratio.

  • Save GalleryGrid.tsx.
  • Switch to your browser and visit /fr/gallery.

You should see the "Gallery of Discovery" heading with an orange underline on "Discovery", a description paragraph, and all your gallery images displayed in a responsive grid.

Don't see any images?

  • Check that your gallery images exist in public/gallery/ and that the imageUrl values in your seed script match those file paths (e.g., /gallery/nature-1.jpg).
  • Make sure your dev server is still running. If you restarted it, confirm the GalleryItem seed data is present in MongoDB by re-running npx tsx lib/seed.ts.
  • Verify that GalleryGrid.tsx starts with 'use client' on the very first line. Without it, the component cannot use useState.

Why aren't my gallery images showing up on /fr/gallery?

Add category filter tabs and the Share Your Moments CTA

The grid shows every image, but parents have no way to narrow it down. Nature shots, art projects, and discovery activities are all mixed together. The filtering logic is already in your component. It just needs buttons to trigger it.

  • In GalleryGrid.tsx, find this line:
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
  • Copy the following filter buttons code directly above that line:
      {/* Category filter tabs */}
      <div className="flex flex-wrap gap-3 mb-8">
        {categories.map(cat => (
          <button key={cat} onClick={() => setActive(cat)} className={`px-5 py-2 rounded-full text-sm font-semibold transition-colors ${active === cat ? 'bg-[#8529c6] text-white' : 'bg-[#f3ede6] text-[#4d4353] border border-[#d0c2d5]'}`}>
            {cat === 'all' ? t('catAll') : t(`cat${cat}` as any)}
          </button>
        ))}
      </div>

What do these buttons do?

Each button calls setActive(cat) when clicked, which updates the active state and triggers filtered to re-derive the visible images.

The active tab gets the primary purple background (bg-[#8529c6] text-white). Inactive tabs use a neutral surface with a border. The label comes from your JSON translations: t('catAll') for the All button, and t('catNature'), t('catArt'), t('catDiscovery') for the others.

  • Save GalleryGrid.tsx.

Before you refresh, what do you think will happen when you click the Nature tab?

  • Refresh /fr/gallery in your browser.
  • Click the Nature tab.

The grid updates instantly to show only nature-tagged images. No page reload needed. Click All to bring everything back.

Tabs not filtering?

  • Confirm the categories constant at the top of GalleryGrid.tsx uses capital letters: 'Nature', 'Art', 'Discovery'. These must match the category enum values in your GalleryItem model exactly.
  • Make sure the filter buttons JSX sits above the grid <div>, not inside it.

Why aren't my gallery category filter tabs working?

✔️ Awesome, I've got everything!

Great, double check you have saved GalleryGrid.tsx.

ⓧ I'd like to double check the full code

Scroll up and confirm your GalleryGrid.tsx contains these sections in order:

  • The 'use client' directive, imports for useState, useTranslations, and Image.
  • The GalleryItemData and GalleryGridProps interfaces, followed by the categories constant.
  • The GalleryGrid component function with useState, the filtered variable, the headline, description, filter buttons, and the image grid.

The gallery grid is working and filtering is live. Now let's add a call-to-action section below the grid that encourages parents to share their own school photos.

  • Create a new file at components/ShareMoments.tsx and add this component:
import { useTranslations } from 'next-intl';

export default function ShareMoments() {
  const t = useTranslations('GalleryPage');
  return (
    // Centered CTA card encouraging parents to share school photos
    <section className="px-4 md:px-16 py-16 text-center">
      <div className="max-w-md mx-auto bg-[#fef8f1] rounded-[3rem] p-12">
        <div className="w-16 h-16 rounded-full bg-[#f2daff] flex items-center justify-center text-3xl mx-auto mb-6">{'\u{1F4F7}'}</div>
        <h2 className="text-2xl font-bold mb-3" style={{ fontFamily: 'Plus Jakarta Sans' }}>{t('shareTitle')}</h2>
        <p className="text-[#4d4353] mb-6" style={{ fontFamily: 'Be Vietnam Pro' }}>{t('shareDescription')}</p>
        <button className="px-8 py-3 rounded-full bg-[#cf4345] text-white font-semibold">{t('uploadButton')}</button>
      </div>
    </section>
  );
}

What does this component do?

This is a call-to-action card encouraging parents to share photos. It uses the GalleryPage translation namespace for all its text.

The camera emoji sits inside a purple-tinted circle (bg-[#f2daff]). The Upload Photo button uses the tertiary-container color (bg-[#cf4345]) from the Chromatic Discovery design system.

  • Save components/ShareMoments.tsx. You should see the file appear in the components/ folder in the sidebar.

✔️ Awesome, I've got everything!

Great, double check you have saved ShareMoments.tsx.

ⓧ I'd like to double check the full code

import { useTranslations } from 'next-intl';

export default function ShareMoments() {
  const t = useTranslations('GalleryPage');
  return (
    <section className="px-4 md:px-16 py-16 text-center">
      <div className="max-w-md mx-auto bg-[#fef8f1] rounded-[3rem] p-12">
        <div className="w-16 h-16 rounded-full bg-[#f2daff] flex items-center justify-center text-3xl mx-auto mb-6">{'\u{1F4F7}'}</div>
        <h2 className="text-2xl font-bold mb-3" style={{ fontFamily: 'Plus Jakarta Sans' }}>{t('shareTitle')}</h2>
        <p className="text-[#4d4353] mb-6" style={{ fontFamily: 'Be Vietnam Pro' }}>{t('shareDescription')}</p>
        <button className="px-8 py-3 rounded-full bg-[#cf4345] text-white font-semibold">{t('uploadButton')}</button>
      </div>
    </section>
  );
}

The ShareMoments component is ready but your gallery page does not import it yet. Let's update the page to wire in ShareMoments and add bilingual SEO metadata at the same time.

  • Replace the entire contents of app/[locale]/gallery/page.tsx with the complete version that adds SEO metadata and the ShareMoments component:
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import { dbConnect } from '@/lib/dbConnect';
import { GalleryItem } from '@/models/GalleryItem';
import GalleryGrid from '@/components/GalleryGrid';
import ShareMoments from '@/components/ShareMoments';

// Bilingual SEO metadata from JSON translation files
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'GalleryPage' });
  return { title: t('title'), description: t('description') };
}

export default async function GalleryPage({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  // Fetch all gallery items from MongoDB sorted by display order
  await dbConnect();
  const items = await GalleryItem.find().sort({ order: 1 }).lean();
  const serialized = items.map(item => ({ ...item, _id: String(item._id) }));
  return (
    <>
      <GalleryGrid items={serialized} locale={locale} />
      <ShareMoments />
    </>
  );
}

What changed?

generateMetadata reads the GalleryPage.title and GalleryPage.description translation keys from your JSON files to set bilingual page titles and meta descriptions. This follows the same pattern as your About and Curriculum pages.

<ShareMoments /> renders below the grid inside the same fragment, so parents see the CTA after scrolling through the photos.

  • Save gallery/page.tsx.
  • Refresh /fr/gallery in your browser.

Scroll down past the image grid. You should see a cream-colored card with a camera emoji, a "Share Your Moments" heading, a description, and a red Upload Photo button.

Don't see the Share Your Moments card?

  • Check that import ShareMoments from '@/components/ShareMoments' is in your gallery page imports.
  • Make sure <ShareMoments /> appears inside the fragment after <GalleryGrid ... />.

Why isn't my ShareMoments CTA showing on the gallery page?

✔️ Awesome, I've got everything!

Great, double check you have saved gallery/page.tsx.

ⓧ I'd like to double check the full code

import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import { dbConnect } from '@/lib/dbConnect';
import { GalleryItem } from '@/models/GalleryItem';
import GalleryGrid from '@/components/GalleryGrid';
import ShareMoments from '@/components/ShareMoments';

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'GalleryPage' });
  return { title: t('title'), description: t('description') };
}

export default async function GalleryPage({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  await dbConnect();
  const items = await GalleryItem.find().sort({ order: 1 }).lean();
  const serialized = items.map(item => ({ ...item, _id: String(item._id) }));
  return (
    <>
      <GalleryGrid items={serialized} locale={locale} />
      <ShareMoments />
    </>
  );
}
  • Switch your browser to /en/gallery.

All headings, category labels, and the Share Your Moments text update to English. The filter tabs work the same way in both languages. Check the browser tab to confirm the bilingual page title appears.

Your Gallery page is live with category filtering and a beautiful CTA section. Next up, you will build the Blog page to give the school a place for news and updates.

Build the Blog Page

Your Gallery page is live with category filtering and a share CTA. The site now has strong visual content, but no written content for news and updates.

A blog lets the school share educational updates, event recaps, and classroom stories. In this step, you will build the final content page by loading article data from MongoDB and displaying it in a card layout with thumbnails, dates, and bilingual excerpts.

In this step, get ready to:
  • Create a BlogCard component with thumbnail images, formatted dates, bilingual titles, and excerpts.
  • Build the Blog page route that fetches published posts from MongoDB and renders them in a responsive grid.
  • Verify all six pages (Home, About, Curriculum, Gallery, Blog, Contact) work with bilingual content from the navbar.
Create the BlogCard component

Each blog post renders as a card with a thumbnail, a formatted date, the bilingual title, and a truncated excerpt. The card also uses Motion scroll animations so it fades up as you scroll, matching the Curriculum and About pages.

  • Create a new file at components/BlogCard.tsx and add the imports and TypeScript interface by pasting this code:
'use client';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import { motion } from 'motion/react';

interface BlogCardProps {
  post: {
    _id: string;
    slug: string;
    title: { fr: string; en: string };
    excerpt: { fr: string; en: string };
    date: string;
    imageUrl?: string;
  };
  locale: string;
  index: number;
}

What does this code do?

The 'use client' directive marks this as a Client Component because it uses Motion animations and useTranslations.

The BlogCardProps interface defines the shape of data each card expects: a serialized blog post with bilingual title and excerpt, the current locale for language selection, and an index for staggered animation delay.

  • Below the interface in the same file, add the BlogCard component function:
export default function BlogCard({ post, locale, index }: BlogCardProps) {
  const t = useTranslations('BlogPage');
  const lang = locale as 'fr' | 'en';
  const formattedDate = new Date(post.date).toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' });

  return (
    <motion.article initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.1, duration: 0.5 }} viewport={{ once: true }} className="rounded-[1.5rem] bg-[#f9f3ec] border border-[#d0c2d5] overflow-hidden">
      {post.imageUrl && (
        <div className="relative aspect-[16/9]">
          <Image src={post.imageUrl} alt={post.title[lang]} fill className="object-cover" sizes="(max-width: 768px) 100vw, 50vw" />
        </div>
      )}
      <div className="p-6">
        <span className="text-sm text-[#7f7384]">{formattedDate}</span>
        <h2 className="text-xl font-bold mt-2 mb-3" style={{ fontFamily: 'Plus Jakarta Sans' }}>{post.title[lang]}</h2>
        <p className="text-[#4d4353] text-sm line-clamp-3" style={{ fontFamily: 'Be Vietnam Pro' }}>{post.excerpt[lang]}</p>
      </div>
    </motion.article>
  );
}

What does this component do?

  • formattedDate converts the ISO date string into a locale-aware format. French users see "14 juin 2026" while English users see "June 14, 2026".
  • motion.article wraps the card in a scroll animation. Each card starts invisible and 30px below its final position, then fades up when it enters the viewport. The delay: index * 0.1 staggers cards so they appear one after another.
  • line-clamp-3 truncates the excerpt to three lines, keeping the card height consistent across posts.
  • Save components/BlogCard.tsx.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm both chunks are present in components/BlogCard.tsx, in this order:

  • The 'use client' directive, four imports (useTranslations, Image, motion), and the BlogCardProps interface.
  • The BlogCard function with formattedDate, motion.article wrapper, conditional thumbnail image, date label, title, and line-clamp-3 excerpt.
Build the Blog page with SEO metadata

The Blog page follows the same Server Component pattern you used for the Gallery and Curriculum pages. It connects to MongoDB, queries for published posts, serializes the results, and passes them to the client-side BlogCard components.

Are BlogPage translations already seeded?

Yes. Your JSON translation files already contain the BlogPage namespace with keys for the page title, description, and read-more text in both messages/fr.json and messages/en.json.

  • Create a new file at app/[locale]/blog/page.tsx and paste the following code:
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import { dbConnect } from '@/lib/dbConnect';
import { BlogPost } from '@/models/BlogPost';
import BlogCard from '@/components/BlogCard';

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'BlogPage' });
  return { title: t('title'), description: t('description') };
}

export default async function BlogPage({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  await dbConnect();
  const posts = await BlogPost.find({ published: true }).sort({ date: -1 }).lean();
  const serialized = posts.map(p => ({ ...p, _id: String(p._id), date: p.date.toISOString() }));
  return (
    <section className="px-4 md:px-16 py-16">
      <h1 className="text-4xl font-extrabold mb-8" style={{ fontFamily: 'Plus Jakarta Sans' }}>Blog</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl">
        {serialized.map((post, i) => (
          <BlogCard key={post._id} post={post} locale={locale} index={i} />
        ))}
      </div>
    </section>
  );
}

What does this page do?

  • generateMetadata uses getTranslations from next-intl/server to set a bilingual browser tab title and meta description. This is the same pattern you used for the About, Curriculum, and Gallery pages.
  • BlogPost.find({ published: true }).sort({ date: -1 }) fetches only published posts, sorted newest first.
  • The serialization step converts _id to a string and date to an ISO string. Mongoose returns BSON objects and Date instances that cannot be passed as props to a Client Component.
  • The responsive grid uses 1 column on mobile and 2 columns at the md breakpoint.
  • Save app/[locale]/blog/page.tsx.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete app/[locale]/blog/page.tsx file:

import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import { dbConnect } from '@/lib/dbConnect';
import { BlogPost } from '@/models/BlogPost';
import BlogCard from '@/components/BlogCard';

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'BlogPage' });
  return { title: t('title'), description: t('description') };
}

export default async function BlogPage({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  await dbConnect();
  const posts = await BlogPost.find({ published: true }).sort({ date: -1 }).lean();
  const serialized = posts.map(p => ({ ...p, _id: String(p._id), date: p.date.toISOString() }));
  return (
    <section className="px-4 md:px-16 py-16">
      <h1 className="text-4xl font-extrabold mb-8" style={{ fontFamily: 'Plus Jakarta Sans' }}>Blog</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl">
        {serialized.map((post, i) => (
          <BlogCard key={post._id} post={post} locale={locale} index={i} />
        ))}
      </div>
    </section>
  );
}

Before you check the browser, what do you expect to see at /fr/blog? How many cards, and in what language?

  • In your browser, navigate to http://localhost:3000/fr/blog.

You should see four blog post cards in a two-column grid. Each card shows a thumbnail image, a date in French format, a French title, and a truncated French excerpt. The cards fade up with a stagger as you scroll.

  • Switch to http://localhost:3000/en/blog and confirm all titles, excerpts, and dates update to English.
  • Check the browser tab. It should show the bilingual title from your generateMetadata function (e.g. "Blog | Ecole Les Caramboles").

Blog page showing no cards?

  • Make sure your seed script has been run since you added the BlogPost seeds in Step 1. Check the terminal output from npx tsx lib/seed.ts for the blog post count.
  • Confirm your seed data uses published: true on each blog post. The query filters for published posts only.
  • Check the dev server terminal for error messages. A missing import or typo in the file path will show a build error.

Why aren't my blog cards rendering on the page?

Verify full site navigation across all six pages

The Blog page is the last of your four new pages. The site now has six pages total. Page labels and UI text come from JSON translation files, while blog post content and gallery items come from MongoDB.

  • Starting from the navbar, click through each page in order: Home, About, Curriculum, Gallery, Blog, Contact.
  • On each page, toggle between French and English using the language switcher.
  • Confirm every page renders its content in the selected language. All text comes from MongoDB translations.

A page is missing from the navbar?

  • Check your Nav namespace in the seed translations. Each page link needs a matching key in both locale documents.
  • Make sure the page route folder name matches the link href in your navigation component (e.g. /blog for app/[locale]/blog/page.tsx).

Help me fix my site navigation so all six pages are accessible.

Secret mission

Build the Newsletter Signup with MongoDB Subscribers

The school wants parents to subscribe for updates right from the website. Can you build a newsletter signup component that saves subscribers to MongoDB and appears above the Footer on every page? You will create a Subscriber model, a server action with email validation, and a client component with success and error states.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine with a local MongoDB database, 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:

  • The les-caramboles/ project folder on your Desktop, containing les-caramboles-frontend/ with all source code, components, and models.
  • The lescaramboles MongoDB database with Program, BlogPost, and GalleryItem collections.
  • The running Next.js dev server process in your terminal.

Keep everything running

No action needed. Choose this if you plan to continue with the Les Caramboles series.

This project is part of a five-project series. Project 3 builds the enrollment portal and Project 4 builds the LMS that manages all the content you just seeded. Keeping your files, database, and dev server running means you can pick up right where you left off.

  • Your dev server continues running at http://localhost:3000.
  • Your MongoDB data persists automatically on disk.
  • All six pages (Home, About, Curriculum, Gallery, Blog, Contact) remain accessible in both French and English.

Pause - I'll come back to this later

Shut down the running dev server to free up your terminal, but keep your project files and database intact.

  • Press Ctrl+C in the terminal running the dev server to stop it.
  • Your MongoDB data persists on disk between sessions. No need to re-seed when you return.
  • When you come back, open the les-caramboles-frontend/ folder in VS Code and start the dev server again by running:
npm run dev

Delete - I don't want to use this again

Remove all project files and database data. This deletes your work from both Project 1 and Project 2 of the Les Caramboles series.

  • Press Ctrl+C in the terminal to stop the dev server.
  • Delete the project folder on macOS or Linux by running:
rm -rf ~/Desktop/les-caramboles
  • On Windows, run:
rmdir /s /q "%USERPROFILE%\Desktop\les-caramboles"
  • Drop the MongoDB database by running:
mongosh lescaramboles --eval "db.dropDatabase()"

You should see { ok: 1, dropped: 'lescaramboles' } confirming the database has been removed.

Nice Work!

Nice Work!

You just extended the Les Caramboles school website from a single homepage into a complete six-page bilingual site. Static page content loads from JSON translation files, while dynamic data like blog posts and gallery items comes from MongoDB. Every page toggles between French and English with one click and follows the Chromatic Discovery design system.

What you learned:

  • Created Mongoose models for dynamic content and seeded MongoDB with blog posts and gallery items, keeping static translations in JSON files where next-intl reads them via getRequestConfig.
  • Built an About page with a philosophy hero and animated pillar cards, and a Curriculum page where Server Components fetch bilingual program data from MongoDB with Motion whileInView scroll animations and a visual daily schedule.
  • Built a Gallery page with client-side category filtering using useState, and a Blog page with article cards fetched from MongoDB, both with bilingual SEO metadata via generateMetadata.
  • Completed an optional Secret Mission to push your skills further with a new feature wired into the layout and backed by MongoDB.

Ready to quiz yourself?