Build a Multi-Page School Website
Expand a Next.js landing page into a bilingual multi-page site with theming.
Introduction
30 Second Summary
When parents are looking for a school, they expect the website to feel complete. They want to browse multiple pages, find the school on a map, and sign up for updates.
In this project, you will expand your bilingual landing page into a multi-page school website using Next.js. You will add professional navigation, dark/light theming, a newsletter subscription backed by MongoDB, and a Contact page with a Google Maps embed.
What You'll Build
Hand a parent your phone and watch them tap between Home, About, and Contact, toggle dark mode, spot the school on an embedded map, and then get a newsletter popup sliding in ten seconds later.
By the end of this project, you'll have:
- A responsive navigation bar with a language switcher and dark/light theme toggle, a footer with social media links, and a full-width enrollment CTA banner that all appear on every page.
- A redesigned Home page with the school logo, hero tagline, and preview cards, plus a full About page with the founder's story and three educational pillar cards (Nature, Art, Discovery) in a responsive grid.
- A Contact page where parents find the school address and see the location on an embedded Google Map, plus a newsletter popup that collects email addresses and saves them to MongoDB through a Next.js API route.
- Secret Mission: Build a responsive Gallery page with a masonry-style image grid and lightbox preview.
Are there any prerequisites?
This project builds directly on Project 1 (Build a Bilingual School Landing Page). You need a running les-caramboles project with Shadcn UI, next-intl, MongoDB, and Motion already configured before starting.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Your Project 1 landing page is about to become a full multi-page school website with professional navigation, dark/light theming, and a newsletter subscription system backed by MongoDB.
Install next-themes and Create Page Routes
Your Project 1 site is a single bilingual landing page with no way for visitors to navigate to other pages. It also has no dark mode support.
Before you can build out new pages or add a polished theme toggle, you need two things in place: a routing structure for additional pages and a theming system that handles dark and light mode without a flash on load. In this step, you'll install the tools for both and create placeholder routes for the About and Contact pages.
In this step, get ready to:
- Install next-themes and the Shadcn UI components you'll use across the site.
- Create a ThemeProvider and wire it into your locale layout for dark/light mode.
- Create placeholder About and Contact page routes and verify they load in the browser.
Install next-themes and Shadcn UI components
The next-themes library handles dark/light mode switching in Next.js apps. It prevents the white flash you normally see on page load by injecting a script before the page renders.
- In your VS Code terminal, install next-themes by running this command:
npm install next-themes@0.4.6
What is next-themes?
next-themes manages dark and light mode for your site. It syncs the theme across browser tabs, respects the visitor's system preference, and avoids the bright flash on page load that manual implementations struggle with.
You should see next-themes added to your package.json dependencies.
You'll also need three Shadcn UI components for the pages you'll build later: Dialog (for the newsletter popup), Sheet (for the mobile navigation drawer), and Input (for form fields).
- Add all three Shadcn components at once by running this command:
npx shadcn@latest add dialog sheet input
What do these components do?
Dialog is a modal overlay. You'll use it for the newsletter subscription popup in Step 5.
Sheet is a slide-in drawer. It will replace your current mobile nav with a more polished, accessible drawer in Step 2.
Input is a styled text field for collecting user input like email addresses.
- Confirm the new component files exist by running:
ls components/ui/
You should see dialog.tsx, sheet.tsx, and input.tsx in the list alongside your existing Shadcn components from Project 1.
Command not found when running npx?
Make sure you are running the command from inside your les-caramboles project folder. If your terminal opened in a different directory, run cd ~/Desktop/les-caramboles first.
help me fix Shadcn component installation errors
Create the ThemeProvider and update the layout
next-themes needs a provider component that wraps your app. In the Next.js App Router, providers that use browser APIs must be client components. You'll create a thin wrapper that re-exports the next-themes provider as a client component.
- Create a new file called ThemeProvider.tsx inside your components/ folder.
- Paste the following code into the file:
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
What does this code do?
'use client' marks this as a client component. The next-themes provider needs access to browser APIs (like localStorage) to persist the user's theme choice, so it can only run on the client.
The wrapper re-exports ThemeProvider from next-themes under your own name and passes all props straight through. This lets your server component layout import a client-safe provider.
- Save ThemeProvider.tsx.
Now you need to wire this provider into your locale layout so every page gets theme support. You'll wrap the existing NextIntlClientProvider with your new ThemeProvider and add suppressHydrationWarning to the <html> tag.
- Open app/[locale]/layout.tsx in VS Code.
- Replace the entire file contents with this code:
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { ThemeProvider } from '@/components/ThemeProvider';
export default async function LocaleLayout({ children, params: { locale } }: { children: React.ReactNode; params: { locale: string } }) {
const messages = await getMessages();
return (
<html lang={locale} suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</ThemeProvider>
</body>
</html>
);
}
What changed in the layout?
suppressHydrationWarning on the <html> tag tells React not to warn when next-themes modifies the tag before hydration. This is how next-themes prevents the white flash on load.
attribute="class" tells next-themes to toggle a dark class on the <html> element. This is what activates Tailwind CSS's dark: utility classes across your entire site.
enableSystem makes the theme respect the visitor's operating system preference (dark or light) by default.
- Save layout.tsx.
- Start your dev server by running this command:
npm run dev
- Open localhost:3000/fr in your browser.
Your Home page should load as before. If your system is set to dark mode, you may already see a subtle difference in background. If it is set to light mode, the page looks the same as before.
Seeing a hydration error in the console?
Check that suppressHydrationWarning is on the <html> tag, not on <body>. Also confirm the 'use client' directive is the very first line in ThemeProvider.tsx.
help me fix my ThemeProvider hydration error
Create placeholder About and Contact pages
Next.js App Router uses file-system routing. To add a new page, you create a folder with a page.tsx file inside it. Since your site uses locale-based routing under app/[locale]/, you'll create the About and Contact folders inside that directory.
- In the VS Code Explorer sidebar, right-click the app/[locale]/ folder.
- Click New File.
- Type about/page.tsx and press Enter. VS Code creates both the about/ folder and the file.
- Paste this placeholder code into the file:
export default function AboutPage() {
return <h1>About</h1>;
}
- Save about/page.tsx.
- Navigate to localhost:3000/fr/about in your browser.
You should see the word "About" rendered as a heading on the page.
- Now create the Contact page the same way. Right-click the app/[locale]/ folder again.
- Click New File.
- Type contact/page.tsx and press Enter.
- Paste this placeholder code into the file:
export default function ContactPage() {
return <h1>Contact</h1>;
}
- Save contact/page.tsx.
Before you check the Contact page, take a guess. What do you expect to see when you navigate to localhost:3000/fr/contact?
- Navigate to localhost:3000/fr/contact in your browser.
You should see the word "Contact" rendered as a heading. Both placeholder pages are live.
How does file-system routing work?
Next.js maps your folder structure directly to URL paths. The file at app/[locale]/about/page.tsx becomes the route /fr/about (or /en/about). The [locale] folder is a dynamic segment that matches fr or en based on the URL.
Seeing a 404 page?
Check that the folder structure matches exactly: app/[locale]/about/page.tsx. The file must be named page.tsx (not index.tsx or about.tsx). Also make sure the about/ folder is inside [locale]/, not directly inside app/.
help me fix my About or Contact page 404
Now verify that the theme system is working. You'll check this by changing your operating system's appearance preference.
- On macOS, open System Settings, then Appearance, and switch between Light and Dark. On Windows, open Settings, then Personalization, then Colors, and change the mode.
- Return to your browser and check that the page responds to the change without a flash or full reload.
The page background should shift between light and dark to match your system setting. The text content will not look polished yet since you have not added dark mode styles to your components. That is expected for now.
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is each file you should have at the end of this step. Compare your code against these.
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { ThemeProvider } from '@/components/ThemeProvider';
export default async function LocaleLayout({ children, params: { locale } }: { children: React.ReactNode; params: { locale: string } }) {
const messages = await getMessages();
return (
<html lang={locale} suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</ThemeProvider>
</body>
</html>
);
}
export default function AboutPage() {
return <h1>About</h1>;
}
export default function ContactPage() {
return <h1>Contact</h1>;
}
Your routing structure and theme foundation are in place. Next up, you'll build the shared Header with navigation links, a theme toggle button, and a Footer that appears on every page.
Build the Shared Header and Footer
Your site now has theme support and page routes, but visitors have no way to move between pages. Clicking a link in the URL bar is the only option.
Every professional website needs a shared layout that wraps every page in a consistent frame. In this step, you will build a navigation header with a theme toggle, a footer with social links, and an enrollment CTA banner that appears on every page.
In this step, get ready to:
- Build a Header with desktop navigation, a mobile slide-in drawer, a language switcher, and a dark/light theme toggle.
- Build a Footer with social icons and a CTA Banner with an enrollment button.
- Wire the Header, Footer, and CTA Banner into the locale layout so they appear on every page.
Build the theme toggle and navigation header
The header needs two small components before it can come together: a ThemeToggle button that switches between light and dark mode, and the NavigationMenu from Shadcn UI for accessible desktop navigation.
- Add the Shadcn UI NavigationMenu component to your project by running this command:
npx shadcn@latest add navigation-menu
Seeing a prompt about overwriting files?
If the CLI asks about overwriting existing files, select no to keep your current components. The NavigationMenu is a new addition and should install without conflicts.
Why isn't the NavigationMenu component installing?
- Create a new file at components/ThemeToggle.tsx and paste in the following code:
'use client';
import { useTheme } from 'next-themes';
import { Sun, Moon } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
// Get the current theme and the setter from next-themes
const { theme, setTheme } = useTheme();
return (
<Button variant="ghost" size="icon" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} aria-label="Toggle theme">
{/* Sun icon visible in light mode, hidden in dark mode */}
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
{/* Moon icon hidden in light mode, visible in dark mode */}
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
);
}
What does this code do?
The useTheme hook from next-themes reads the current theme and provides setTheme to flip between dark and light.
The Sun and Moon icons from Lucide swap places using Tailwind CSS dark: variants. In light mode the Sun shows and the Moon hides. In dark mode the reverse happens, with a smooth rotate-and-scale transition.
- Save components/ThemeToggle.tsx.
Before the Header can reference translation keys like nav.home, nav.about, and nav.contact, those keys need to exist in your translation files.
- Open messages/fr.json and make sure a nav section exists with these keys:
"nav": {
"home": "Accueil",
"about": "À propos",
"contact": "Contact"
}
- Open messages/en.json and add the same section in English:
"nav": {
"home": "Home",
"about": "About",
"contact": "Contact"
}
- Save both translation files.
Now you can build the Header itself. The desktop version uses Shadcn's NavigationMenu for accessible keyboard navigation and hover states.
- Create a new file at components/Header.tsx and paste in the desktop navigation code:
'use client';
import Link from 'next/link';
import { useTranslations, useLocale } from 'next-intl';
import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuLink } from '@/components/ui/navigation-menu';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Menu, Instagram, Facebook } from 'lucide-react';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { ThemeToggle } from '@/components/ThemeToggle';
export function Header() {
// Get translated nav labels and current locale for link paths
const t = useTranslations('nav');
const locale = useLocale();
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur">
<div className="container flex h-16 items-center justify-between">
{/* School name as a home link */}
<Link href={`/${locale}`} className="font-bold text-lg">Les Caramboles</Link>
{/* Desktop navigation menu, hidden on mobile */}
<NavigationMenu className="hidden md:flex">
<NavigationMenuList>
<NavigationMenuItem><NavigationMenuLink asChild><Link href={`/${locale}`}>{t('home')}</Link></NavigationMenuLink></NavigationMenuItem>
<NavigationMenuItem><NavigationMenuLink asChild><Link href={`/${locale}/about`}>{t('about')}</Link></NavigationMenuLink></NavigationMenuItem>
<NavigationMenuItem><NavigationMenuLink asChild><Link href={`/${locale}/contact`}>{t('contact')}</Link></NavigationMenuLink></NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
{/* Desktop language switcher and theme toggle */}
<div className="hidden md:flex items-center gap-2">
<LanguageSwitcher />
<ThemeToggle />
</div>
</div>
</header>
);
}
What does this code do?
- useTranslations('nav') loads the nav translation keys you just added so link labels change with the locale.
- NavigationMenuLink asChild tells Shadcn to render the Next.js Link as the actual anchor element, giving you client-side routing with built-in keyboard navigation and hover styles.
- hidden md:flex hides the desktop nav on small screens. The mobile version comes next.
Why NavigationMenu instead of plain links?
Shadcn's NavigationMenu is built on Radix UI and provides accessible keyboard navigation, focus management, and hover indicator animations out of the box. For a school website where accessibility matters, this is a better fit than plain Link components that would need manual ARIA attributes.
- Save components/Header.tsx.
To see the Header in action, you need to add it to the locale layout temporarily.
- Open app/[locale]/layout.tsx.
- Add this import at the top of the file, below the existing imports:
import { Header } from '@/components/Header';
- Find the line {children} inside the NextIntlClientProvider and add <Header /> directly above it:
<Header />
{children}
- Save app/[locale]/layout.tsx and switch to your browser at localhost:3000/fr.
You should see a sticky header bar at the top of the page with "Les Caramboles" on the left, navigation links (Accueil, À propos, Contact) in the center, your language switcher, and a Sun/Moon icon on the right.
- Click the Sun/Moon icon to toggle between light and dark mode.
- Click À propos in the nav to confirm it navigates to /fr/about and the header stays visible.
Don't see the navigation links?
- Check that your messages/fr.json has a nav object with home, about, and contact keys.
- Make sure the JSON is valid. A missing comma or bracket will silently break translations.
- Confirm that <Header /> is inside the NextIntlClientProvider wrapper in your layout. Placing it outside means translations are not available.
Why aren't my Header navigation links showing up?
The desktop nav works. Now add the mobile navigation using Shadcn's Sheet component, which provides a slide-in drawer with built-in focus trapping and backdrop overlay.
- Return to components/Header.tsx.
- Find the closing </div> tag that sits right after the desktop controls wrapper (<div className="hidden md:flex items-center gap-2">). This is the closing tag for the container div.
- Paste the following Sheet code directly above that closing </div>:
{/* Mobile hamburger menu, hidden on desktop */}
<Sheet>
<SheetTrigger asChild className="md:hidden">
<Button variant="ghost" size="icon"><Menu className="h-5 w-5" /></Button>
</SheetTrigger>
<SheetContent side="left" className="w-72">
<nav className="flex flex-col gap-4 mt-8">
<Link href={`/${locale}`}>{t('home')}</Link>
<Link href={`/${locale}/about`}>{t('about')}</Link>
<Link href={`/${locale}/contact`}>{t('contact')}</Link>
</nav>
<div className="flex items-center gap-2 mt-6">
<LanguageSwitcher />
<ThemeToggle />
</div>
<div className="flex gap-3 mt-4">
<a href="https://instagram.com" aria-label="Instagram"><Instagram className="h-5 w-5" /></a>
<a href="https://facebook.com" aria-label="Facebook"><Facebook className="h-5 w-5" /></a>
</div>
</SheetContent>
</Sheet>
What does this code do?
- The Sheet component creates a slide-in drawer. side="left" makes it slide from the left edge of the screen.
- The SheetTrigger hamburger icon only shows on mobile (md:hidden), while the desktop NavigationMenu is hidden on mobile (hidden md:flex).
- The drawer includes nav links, the language switcher, the theme toggle, and social media icons so mobile visitors have full access to everything.
- Save components/Header.tsx.
- In your browser, resize the window to a narrow width (below 768px) or open DevTools and toggle to a mobile viewport.
The desktop nav links should disappear and a hamburger menu icon should appear on the right side of the header.
- Click the hamburger icon. A drawer should slide in from the left with nav links, the language switcher, the theme toggle, and social icons.
Sheet drawer not opening?
- Make sure the Sheet code is inside the <div className="container ..."> wrapper, not outside it.
- Check that you imported Sheet, SheetContent, and SheetTrigger from @/components/ui/sheet at the top of your file.
Why isn't my Shadcn Sheet drawer opening on mobile?
✔️ Awesome, I've got everything!
Great. Make sure you have saved components/Header.tsx and components/ThemeToggle.tsx.
ⓧ I'd like to double check the full code
The Header file is built from two chunks. Scroll up and confirm both are present in your components/Header.tsx, in order:
- Chunk 1: Imports, the Header function with the sticky header, school name link, desktop NavigationMenu, and desktop LanguageSwitcher/ThemeToggle wrapper.
- Chunk 2: The Sheet block (hamburger trigger, SheetContent with nav links, LanguageSwitcher, ThemeToggle, and social icons) sitting inside the container div, before its closing tag.
And here is the full components/ThemeToggle.tsx:
'use client';
import { useTheme } from 'next-themes';
import { Sun, Moon } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button variant="ghost" size="icon" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} aria-label="Toggle theme">
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
);
}
Build the Footer and CTA Banner
A school website needs a footer with contact info and social links, plus a bold call-to-action that nudges parents toward enrollment on every page.
- Create a new file at components/Footer.tsx and paste in the following code:
import { Instagram, Facebook } from 'lucide-react';
export function Footer() {
return (
<footer className="border-t bg-background py-8">
<div className="container flex flex-col md:flex-row items-center justify-between gap-4">
{/* School branding and contact email */}
<div>
<p className="font-bold">Les Caramboles</p>
<p className="text-sm text-muted-foreground">contact@lescaramboles.com</p>
</div>
{/* Social media icon links */}
<div className="flex gap-4">
<a href="https://instagram.com" aria-label="Instagram"><Instagram className="h-5 w-5" /></a>
<a href="https://facebook.com" aria-label="Facebook"><Facebook className="h-5 w-5" /></a>
<a href="https://tiktok.com" aria-label="TikTok" className="text-sm font-bold">TikTok</a>
</div>
{/* Copyright with dynamic year */}
<p className="text-sm text-muted-foreground">© {new Date().getFullYear()} Les Caramboles</p>
</div>
</footer>
);
}
What does this code do?
- The Footer uses Lucide's Instagram and Facebook icons. Lucide does not include a TikTok icon, so a bold text link is used instead.
- new Date().getFullYear() keeps the copyright year current automatically.
- Each social link has an aria-label for screen readers since the icons alone have no text.
- Save components/Footer.tsx.
- Create another new file at components/CTABanner.tsx and paste in this code:
import Link from 'next/link';
export function CTABanner() {
return (
// Full-width enrollment call-to-action using the primary theme color
<section className="bg-primary text-primary-foreground py-10">
<div className="container text-center">
<h2 className="text-2xl font-bold mb-4">Enroll Your Child Today / Inscrivez votre enfant aujourd'hui</h2>
<Link href="https://form.jotform.com/" className="inline-block bg-white text-primary font-semibold px-6 py-3 rounded-lg hover:bg-gray-100 transition">Enroll Now / S'inscrire</Link>
</div>
</section>
);
}
What does this code do?
The CTA Banner is a bold, full-width section using the theme's bg-primary and text-primary-foreground colors, so it automatically adapts to light and dark mode. The button links to the Jotform enrollment form.
- Save components/CTABanner.tsx.
Both components are saved. Now wire them into the layout to see them on every page.
Wire the layout and clean up the home page
All three shared components are ready. The locale layout needs to import them and place them around the page content so every route gets the same frame.
- Switch back to app/[locale]/layout.tsx.
- Add two new imports at the top, below the Header import you added earlier:
import { Footer } from '@/components/Footer';
import { CTABanner } from '@/components/CTABanner';
- Find the section inside NextIntlClientProvider where you have <Header /> and {children}.
- Replace those two lines with this layout structure:
<Header />
<main>{children}</main>
<CTABanner />
<Footer />
What does this layout do?
Wrapping {children} in a <main> tag improves semantics and accessibility. The Header renders above the page content, and the CTABanner and Footer render below it. Because this is in the locale layout, every page under app/[locale]/ gets this frame automatically.
- Save app/[locale]/layout.tsx.
The header, footer, and CTA banner now live in the shared layout. But your home page from Project 1 still has its own inline LanguageSwitcher and social icons, which will duplicate what the Header and Footer already show.
- Open app/[locale]/page.tsx.
- Find and remove the <LanguageSwitcher /> component and its import. This is now rendered by the Header.
- Find and remove any inline social media icon links (Instagram, Facebook, TikTok). These are now rendered by the Footer and mobile drawer.
- Save app/[locale]/page.tsx.
Check for unused imports
After removing the inline components, check the top of page.tsx for any leftover imports that are no longer used (like LanguageSwitcher, Instagram, or Facebook). Remove them to avoid build warnings.
Before you check the result, what do you expect to see when you navigate between pages? Do you think the header, CTA banner, and footer will appear on every page, or only the home page?
- Switch to your browser at localhost:3000/fr.
- Click À propos in the navigation bar to go to the About page.
- Click Contact in the navigation bar to go to the Contact page.
On every page, you should see the sticky header with navigation links at the top, the bold enrollment CTA banner below the page content, and the footer with social icons and the school email at the bottom.
- Click the Sun/Moon icon to toggle the theme. The entire page, including the CTA banner and footer, should switch between light and dark mode.
- Resize the browser to a mobile width. Tap the hamburger icon and confirm the Sheet drawer slides in from the left with all nav links.
CTA Banner or Footer not showing?
- Confirm you imported both Footer and CTABanner in app/[locale]/layout.tsx and placed <CTABanner /> and <Footer /> after <main>{children}</main>.
- Check the browser console for import errors. A typo in the file name or export name will prevent the component from rendering.
- Make sure both components use named exports (export function Footer) matching the named imports in the layout.
Why aren't my Footer and CTA Banner components rendering?
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files.
ⓧ I'd like to double check the full code
Here is the complete app/[locale]/layout.tsx:
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { ThemeProvider } from '@/components/ThemeProvider';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { CTABanner } from '@/components/CTABanner';
export default async function LocaleLayout({ children, params: { locale } }: { children: React.ReactNode; params: { locale: string } }) {
const messages = await getMessages();
return (
<html lang={locale} suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<NextIntlClientProvider messages={messages}>
<Header />
<main>{children}</main>
<CTABanner />
<Footer />
</NextIntlClientProvider>
</ThemeProvider>
</body>
</html>
);
}
Here is the complete components/Footer.tsx:
import { Instagram, Facebook } from 'lucide-react';
export function Footer() {
return (
<footer className="border-t bg-background py-8">
<div className="container flex flex-col md:flex-row items-center justify-between gap-4">
<div>
<p className="font-bold">Les Caramboles</p>
<p className="text-sm text-muted-foreground">contact@lescaramboles.com</p>
</div>
<div className="flex gap-4">
<a href="https://instagram.com" aria-label="Instagram"><Instagram className="h-5 w-5" /></a>
<a href="https://facebook.com" aria-label="Facebook"><Facebook className="h-5 w-5" /></a>
<a href="https://tiktok.com" aria-label="TikTok" className="text-sm font-bold">TikTok</a>
</div>
<p className="text-sm text-muted-foreground">© {new Date().getFullYear()} Les Caramboles</p>
</div>
</footer>
);
}
Here is the complete components/CTABanner.tsx:
import Link from 'next/link';
export function CTABanner() {
return (
<section className="bg-primary text-primary-foreground py-10">
<div className="container text-center">
<h2 className="text-2xl font-bold mb-4">Enroll Your Child Today / Inscrivez votre enfant aujourd'hui</h2>
<Link href="https://form.jotform.com/" className="inline-block bg-white text-primary font-semibold px-6 py-3 rounded-lg hover:bg-gray-100 transition">Enroll Now / S'inscrire</Link>
</div>
</section>
);
}
Your site now has a professional layout frame that wraps every page. Next up, you will redesign the Home page with a hero section, school logo, and preview cards that link to About and Contact.
Redesign the Home Page
Your site now has a polished header, footer, and enrollment CTA banner on every page. The home page itself, though, is still the bare-bones layout from Project 1.
In this step, you'll redesign the home page with the school logo, a bold hero section with the bilingual tagline, and two preview cards that invite visitors to explore the About and Contact pages. Clicking those links still leads to placeholder pages for now, but the home page will set the professional tone for the rest of the site.
In this step, get ready to:
- Build the hero section with the school logo, headline, tagline, and enrollment button.
- Add About Us and Contact Us preview cards with links to the other pages.
- Update the French and English translation files with all new Home page content.
Build the hero section with the school logo and enrollment button
The hero section is the first thing visitors see when they land on the site. It needs to immediately communicate the school's identity with the logo, a bold headline, the tagline, and a clear call-to-action button for enrollment.
- First, make sure you have a logo image file. Place a file named logo.png in your public/ folder (the one at the root of your project, next to app/).
No logo yet?
Any square image will work as a placeholder. You can use the school's actual logo or a temporary image. The Image component will resize it to 120x120 pixels.
- Open app/[locale]/page.tsx in VS Code and replace the entire file contents with this hero section code:
import Image from 'next/image';
import Link from 'next/link';
import { useTranslations, useLocale } from 'next-intl';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { AnimatedSection } from '@/components/AnimatedSection';
export default function HomePage() {
const t = useTranslations('home');
const locale = useLocale();
return (
<div>
{/* Hero section with logo, headline, and enrollment CTA */}
<section className="container py-20 text-center">
<Image src="/logo.png" alt="Les Caramboles" width={120} height={120} className="mx-auto mb-6" />
<h1 className="text-4xl md:text-5xl font-bold mb-4">{t('heroTitle')}</h1>
<p className="text-lg text-muted-foreground mb-8">{t('tagline')}</p>
<Button asChild size="lg">
<Link href="https://form.jotform.com/">{t('ctaLabel')}</Link>
</Button>
</section>
</div>
);
}
What does this code do?
- useTranslations('home') loads the home namespace from your translation JSON files. Every t('key') call pulls the matching string for the current language.
- The Image component from Next.js optimizes the logo (lazy loading, responsive sizing) instead of using a plain <img> tag.
- Button asChild size="lg" tells the Shadcn UI Button to render as the child Link element, giving you a styled link that looks like a large button.
- The imports for Card and AnimatedSection are included now so they're ready for the preview cards you'll add next.
- Save page.tsx and refresh your browser at localhost:3000/fr.
You should see the logo image centered on the page, with a large headline, a tagline line, and a button below it. The text will show raw translation keys (like home.heroTitle) because you haven't added those keys to the translation files yet. That's expected.
Seeing an error instead of the page?
- Check that your imports match exactly. A missing import Image from 'next/image' will cause a build error.
- Make sure you have a logo.png file in your public/ folder. A missing image file won't crash the page, but you'll see a broken image icon.
- Confirm that the Card component exists at components/ui/card.tsx. If it's missing, the import will fail.
Help me debug my Home page hero section
Add the About Us and Contact Us preview cards
Below the hero, visitors should see a quick snapshot of what else the site offers. Two preview cards give them a reason to click through to the About and Contact pages.
- In app/[locale]/page.tsx, find the closing </section> tag of the hero section. Add the following code between that closing </section> and the closing </div>:
{/* Preview cards linking to About and Contact pages */}
<AnimatedSection>
<section className="container py-12 grid md:grid-cols-2 gap-8">
<Card>
<CardHeader><CardTitle>{t('aboutPreviewTitle')}</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">{t('aboutPreviewText')}</p>
<Link href={`/${locale}/about`} className="text-primary font-semibold hover:underline">{t('learnMore')}</Link>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>{t('contactPreviewTitle')}</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">{t('contactPreviewText')}</p>
<Link href={`/${locale}/contact`} className="text-primary font-semibold hover:underline">{t('getInTouch')}</Link>
</CardContent>
</Card>
</section>
</AnimatedSection>
What does this code do?
- AnimatedSection wraps the cards in the fade-in animation component you built in Project 1 using Motion. The cards animate into view as the visitor scrolls down.
- The grid md:grid-cols-2 gap-8 classes create a responsive two-column layout. On desktop, the About and Contact cards sit side by side. On mobile, they stack vertically.
- Each Link uses the locale variable to build the correct bilingual URL, so the visitor stays in their current language when they click through.
- Save page.tsx and refresh your browser.
You should see two cards below the hero section, each showing raw translation key names. They'll fade into view as you scroll thanks to the AnimatedSection wrapper.
Cards not showing up?
- Make sure the new code is placed between the closing </section> of the hero and the closing </div> of the return statement. Placing it outside the <div> wrapper will cause a syntax error.
- Confirm that the AnimatedSection component exists in your components/ folder from Project 1. If you renamed it, update the import to match.
Help me debug my preview cards
✔️ Awesome, I've got everything!
Great. Double check you've saved page.tsx before moving on.
ⓧ I'd like to double check the full code
Your app/[locale]/page.tsx should have these sections in order. Scroll up and confirm each one is present:
- Six import lines at the top: Image, Link, useTranslations and useLocale, Card components, Button, and AnimatedSection.
- The hero <section> block with the Image, h1, tagline p, and Button linking to the Jotform.
- The AnimatedSection block with two Card components in a grid md:grid-cols-2 layout, each with a title, description, and link.
Update the French and English translation files
The page structure is in place, but every piece of text is showing a raw key name. The translation files need a new home namespace with all the hero and preview card strings in both French and English.
- Open messages/fr.json and replace the entire file with this updated version that adds the home namespace:
{
"nav": { "home": "Accueil", "about": "À propos", "contact": "Contact" },
"home": {
"heroTitle": "Bienvenue aux Caramboles",
"tagline": "Une école maternelle bilingue au cœur de Yaoundé",
"ctaLabel": "S'inscrire",
"aboutPreviewTitle": "À propos de nous",
"aboutPreviewText": "Découvrez notre philosophie éducative inspirée du fruit carambole.",
"contactPreviewTitle": "Nous contacter",
"contactPreviewText": "Carrefour Régie, Yaoundé, Cameroun",
"learnMore": "En savoir plus",
"getInTouch": "Nous écrire"
}
}
- Save fr.json and refresh your browser at localhost:3000/fr.
The raw key names should now be replaced with real French text. You should see "Bienvenue aux Caramboles" as the headline, the tagline, and the preview card content in French.
- Now open messages/en.json and replace the entire file with the English version:
{
"nav": { "home": "Home", "about": "About", "contact": "Contact" },
"home": {
"heroTitle": "Welcome to Les Caramboles",
"tagline": "A bilingual preschool in the heart of Yaoundé",
"ctaLabel": "Enroll Now",
"aboutPreviewTitle": "About Us",
"aboutPreviewText": "Discover our educational philosophy inspired by the carambole fruit.",
"contactPreviewTitle": "Contact Us",
"contactPreviewText": "Carrefour Regie, Yaounde, Cameroon",
"learnMore": "Learn More",
"getInTouch": "Get in Touch"
}
}
What's happening in these translation files?
The home object is a new namespace that matches the useTranslations('home') call in your page component. Each key inside it (heroTitle, tagline, etc.) maps to a t() call in the JSX.
next-intl automatically picks the correct file based on the URL locale (/fr/ loads fr.json, /en/ loads en.json). Adding the same keys to both files is what makes the site truly bilingual.
- Save en.json.
Still seeing raw key names after saving?
- Check that the key names in your JSON files match the t() calls exactly. For example, heroTitle in the JSON must match t('heroTitle') in the page. A typo in either will show the raw key.
- Make sure the home namespace is a sibling of nav in the JSON, not nested inside it.
- Try stopping the dev server with Ctrl+C and restarting it with npm run dev. Translation file changes sometimes need a server restart to take effect.
Help me fix my translation keys
✔️ Awesome, I've got everything!
Great. Make sure both fr.json and en.json are saved before the final check.
ⓧ I'd like to double check the full code
Here is the complete fr.json:
{
"nav": { "home": "Accueil", "about": "À propos", "contact": "Contact" },
"home": {
"heroTitle": "Bienvenue aux Caramboles",
"tagline": "Une école maternelle bilingue au cœur de Yaoundé",
"ctaLabel": "S'inscrire",
"aboutPreviewTitle": "À propos de nous",
"aboutPreviewText": "Découvrez notre philosophie éducative inspirée du fruit carambole.",
"contactPreviewTitle": "Nous contacter",
"contactPreviewText": "Carrefour Régie, Yaoundé, Cameroun",
"learnMore": "En savoir plus",
"getInTouch": "Nous écrire"
}
}
And the complete en.json:
{
"nav": { "home": "Home", "about": "About", "contact": "Contact" },
"home": {
"heroTitle": "Welcome to Les Caramboles",
"tagline": "A bilingual preschool in the heart of Yaoundé",
"ctaLabel": "Enroll Now",
"aboutPreviewTitle": "About Us",
"aboutPreviewText": "Discover our educational philosophy inspired by the carambole fruit.",
"contactPreviewTitle": "Contact Us",
"contactPreviewText": "Carrefour Regie, Yaounde, Cameroon",
"learnMore": "Learn More",
"getInTouch": "Get in Touch"
}
}
Time for the final check. Before you refresh, predict what you expect to see: will the headline be in French or English when you visit localhost:3000/fr? What about localhost:3000/en?
- Refresh your browser at localhost:3000/fr.
You should see the redesigned Home page with the Les Caramboles logo centered at the top, a bold "Bienvenue aux Caramboles" headline, the French tagline, and an "S'inscrire" enrollment button. Below that, two preview cards show the About and Contact teasers in French.
- Switch to localhost:3000/en using the language switcher in the header or by editing the URL.
All the text should change to English: "Welcome to Les Caramboles", "A bilingual preschool in the heart of Yaoundé", and "Enroll Now" on the button.
- Click the theme toggle button (Sun/Moon icon) in the header to switch between light and dark mode.
The page should look polished in both themes. The cards, text, and background all adapt to the current theme.
Your Home page now makes a strong first impression with the school's branding, a clear enrollment path, and easy navigation to the rest of the site. Next up, you'll fill in the About page so those preview card links lead somewhere real.
Build the About Page
Your Home page now greets visitors with a polished hero section, bilingual tagline, and preview cards that link to About and Contact. Parents clicking the "Learn More" link on the About preview card land on a bare placeholder heading.
This step fills the About page with three sections that tell the school's story: a presentation built around the carambole fruit metaphor, the founder's background, and the three educational pillars (Nature, Art, Discovery) so families understand what makes Les Caramboles unique.
In this step, get ready to:
- Build the school presentation section with a two-column layout that stacks on mobile.
- Build the founder bio section with an alternating two-column layout.
- Build the three educational pillar cards in a responsive grid and add all About page translations.
Build the school presentation section
The school's identity centres on the carambole fruit metaphor: just like the fruit has many facets, the school nurtures many sides of each child. This section pairs that message with a placeholder image in a two-column grid that collapses on smaller screens.
- Open app/[locale]/about/page.tsx in VS Code.
- Replace the entire file content with the following code:
import { useTranslations } from 'next-intl';
export default function AboutPage() {
// Hook into the 'about' namespace for translated strings
const t = useTranslations('about');
return (
<div className="container py-16">
{/* School presentation: two-column grid, stacks on mobile */}
<section className="grid md:grid-cols-2 gap-12 items-center mb-16">
<div>
<h1 className="text-3xl font-bold mb-4">{t('presentationTitle')}</h1>
<p className="text-muted-foreground">{t('presentationText')}</p>
</div>
<div className="bg-muted rounded-xl h-64 flex items-center justify-center text-muted-foreground">Image placeholder</div>
</section>
</div>
);
}
What does this code do?
- useTranslations('about') pulls all translation keys from the about namespace in your JSON files. The translations don't exist yet, so the page will show the raw key names for now.
- md:grid-cols-2 creates a two-column layout on medium screens and above. On mobile, it falls back to a single stacked column automatically.
- The bg-muted placeholder div stands in for a real school photo. You can swap it for an Image component later.
- Save app/[locale]/about/page.tsx.
- Refresh localhost:3000/fr/about in your browser. You should see a two-column layout with the raw key name about.presentationTitle on the left and a grey placeholder box on the right.
Seeing a blank page or an error?
- Make sure you saved the file. Unsaved changes won't appear in the browser.
- Check that the file path is exactly app/[locale]/about/page.tsx. The folder name [locale] must include the square brackets.
- Confirm your dev server is still running. If it stopped, restart it with npm run dev.
Still stuck? Ask for help.
Add the founder bio section
Below the school presentation, the founder section flips the layout: the image sits on the left and the text on the right. On mobile, order-last md:order-first pushes the image below the text so visitors read the bio first.
- In app/[locale]/about/page.tsx, find the closing </section> tag of the presentation section.
- Add the following founder bio section directly below it, before the closing </div>:
{/* Founder bio: alternating layout (image left, text right) */}
<section className="grid md:grid-cols-2 gap-12 items-center mb-16">
<div className="bg-muted rounded-xl h-64 flex items-center justify-center text-muted-foreground order-last md:order-first">Founder image placeholder</div>
<div>
<h2 className="text-2xl font-bold mb-2">{t('founderName')}</h2>
<p className="text-primary font-semibold mb-4">{t('founderRole')}</p>
<p className="text-muted-foreground">{t('founderBio')}</p>
</div>
</section>
What does this section do?
- The founder section uses the same md:grid-cols-2 grid as the presentation, but the image appears first in the HTML. This creates the visual alternation between text-left/image-right and image-left/text-right.
- order-last md:order-first makes the image appear below the text on mobile (where one column is easier to read), but to the left on desktop.
- Save app/[locale]/about/page.tsx.
- Refresh localhost:3000/fr/about in your browser. Below the presentation section you should now see a second two-column row with a grey placeholder on the left and the raw key names about.founderName and about.founderRole on the right.
Founder section not showing?
- Check that the new <section> sits between the closing </section> of the presentation and the closing </div> of the container. Placing it outside the container hides it from the page layout.
- Make sure there are no missing angle brackets or unclosed tags. A single missing > can break the entire page.
Need help debugging your layout?
Build the pillar cards and add translations
The About page layout is in place, but every text field shows raw key names because the translation files don't have an about namespace yet. Adding translations first means you can verify all three sections at once when you refresh.
- Open messages/en.json in VS Code.
- Find the closing } of the "home" block. Add a comma after it, then paste the following about block below it:
"about": {
"presentationTitle": "Our School",
"presentationText": "Like the multi-faceted carambole fruit, our school nurtures curiosity, creativity, and the flourishing of every child.",
"founderName": "Mrs. Nguemo",
"founderRole": "Founder and Director",
"founderBio": "A passionate educator with over 15 years of experience, Mrs. Nguemo founded Les Caramboles to provide quality bilingual education.",
"pillarsTitle": "Our Educational Pillars",
"pillar_nature_title": "Nature",
"pillar_nature_desc": "Learning outdoors and respecting the environment.",
"pillar_art_title": "Art",
"pillar_art_desc": "Expressing creativity through drawing, music, and theatre.",
"pillar_discovery_title": "Discovery",
"pillar_discovery_desc": "Exploring the world with curiosity and wonder."
}
- Save messages/en.json.
- Open messages/fr.json and add the same comma after the "home" block. Paste the French translations below it:
"about": {
"presentationTitle": "Notre école",
"presentationText": "Comme le fruit carambole aux multiples facettes, notre école cultive la curiosité, la créativité et l'épanouissement de chaque enfant.",
"founderName": "Mme Nguemo",
"founderRole": "Fondatrice et Directrice",
"founderBio": "Éducatrice passionnée avec plus de 15 ans d'expérience, Mme Nguemo a fondé Les Caramboles pour offrir une éducation bilingue de qualité.",
"pillarsTitle": "Nos piliers éducatifs",
"pillar_nature_title": "Nature",
"pillar_nature_desc": "Apprendre en plein air et respecter l'environnement.",
"pillar_art_title": "Art",
"pillar_art_desc": "Exprimer sa créativité à travers le dessin, la musique et le théâtre.",
"pillar_discovery_title": "Découverte",
"pillar_discovery_desc": "Explorer le monde avec curiosité et émerveillement."
}
Why add translations before the pillar cards?
The presentation and founder sections are already in the page but showing raw key names. Adding translations now lets you verify both sections render real text on your next refresh, before you add the pillar cards.
- Save messages/fr.json.
- Refresh localhost:3000/fr/about in your browser. The presentation section should now show "Notre école" as the heading and the carambole metaphor paragraph in French. The founder section should show "Mme Nguemo" with her role and biography.
Still seeing raw key names?
- Check that you added a comma after the closing } of the "home" block. A missing comma makes the entire JSON file invalid.
- Make sure the key names in the JSON match exactly. For example, presentationTitle in the JSON must match t('presentationTitle') in the component.
Still stuck? Ask for help.
Now the existing sections display translated text. Time to add the three educational pillar cards that use Shadcn UI Card components in a responsive grid.
- Switch back to app/[locale]/about/page.tsx.
- Find this import line at the top of the file:
import { useTranslations } from 'next-intl';
- Add the Card component import directly below it so the top of your file looks like this:
import { useTranslations } from 'next-intl';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
- Find the closing </section> of the founder bio section. Add the pillar cards section directly below it, before the closing </div>:
{/* Three educational pillar cards in a responsive grid */}
<section>
<h2 className="text-2xl font-bold text-center mb-8">{t('pillarsTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{['nature', 'art', 'discovery'].map((pillar) => (
<Card key={pillar}>
<CardHeader><CardTitle>{t(`pillar_${pillar}_title`)}</CardTitle></CardHeader>
<CardContent><p className="text-muted-foreground">{t(`pillar_${pillar}_desc`)}</p></CardContent>
</Card>
))}
</div>
</section>
What does this code do?
- The .map() iterates over the three pillar names and renders a Card for each. This avoids writing three near-identical Card blocks by hand.
- Template literals like t(`pillar_${pillar}_title`) dynamically build the translation key. When pillar is "nature", it resolves to pillar_nature_title.
- grid-cols-1 md:grid-cols-3 stacks the cards in a single column on mobile and spreads them across three columns on desktop.
Before you refresh, think about what you expect to see. The pillar cards use translation keys that already exist in your JSON files. Do you think the cards will show translated text or raw key names?
- Save app/[locale]/about/page.tsx.
- Refresh localhost:3000/fr/about in your browser. You should see three cards at the bottom of the page labelled "Nature", "Art", and "Découverte", each with a short French description.
- Switch to localhost:3000/en/about and confirm all text changes to English. The pillar cards should read "Nature", "Art", and "Discovery".
- Resize your browser to a mobile width. The three pillar cards should stack into a single vertical column.
Cards not rendering?
- Check for a missing import. The Card, CardContent, CardHeader, and CardTitle must all be imported from @/components/ui/card.
- Make sure the pillar section sits inside the container div, not outside it. If it falls after the closing </div>, it won't have proper page padding.
Need help with the pillar cards?
✔️ Awesome, I've got everything!
Great. Double check you saved app/[locale]/about/page.tsx, messages/en.json, and messages/fr.json.
ⓧ I'd like to double check the full code
Here is the complete app/[locale]/about/page.tsx file. Compare it against yours line by line.
import { useTranslations } from 'next-intl';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export default function AboutPage() {
const t = useTranslations('about');
return (
<div className="container py-16">
<section className="grid md:grid-cols-2 gap-12 items-center mb-16">
<div>
<h1 className="text-3xl font-bold mb-4">{t('presentationTitle')}</h1>
<p className="text-muted-foreground">{t('presentationText')}</p>
</div>
<div className="bg-muted rounded-xl h-64 flex items-center justify-center text-muted-foreground">Image placeholder</div>
</section>
<section className="grid md:grid-cols-2 gap-12 items-center mb-16">
<div className="bg-muted rounded-xl h-64 flex items-center justify-center text-muted-foreground order-last md:order-first">Founder image placeholder</div>
<div>
<h2 className="text-2xl font-bold mb-2">{t('founderName')}</h2>
<p className="text-primary font-semibold mb-4">{t('founderRole')}</p>
<p className="text-muted-foreground">{t('founderBio')}</p>
</div>
</section>
<section>
<h2 className="text-2xl font-bold text-center mb-8">{t('pillarsTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{['nature', 'art', 'discovery'].map((pillar) => (
<Card key={pillar}>
<CardHeader><CardTitle>{t(`pillar_${pillar}_title`)}</CardTitle></CardHeader>
<CardContent><p className="text-muted-foreground">{t(`pillar_${pillar}_desc`)}</p></CardContent>
</Card>
))}
</div>
</section>
</div>
);
}
And here is the complete messages/en.json file.
{
"nav": { "home": "Home", "about": "About", "contact": "Contact" },
"home": {
"heroTitle": "Welcome to Les Caramboles",
"tagline": "A bilingual preschool in the heart of Yaoundé",
"ctaLabel": "Enroll Now",
"aboutPreviewTitle": "About Us",
"aboutPreviewText": "Discover our educational philosophy inspired by the carambole fruit.",
"contactPreviewTitle": "Contact Us",
"contactPreviewText": "Carrefour Regie, Yaounde, Cameroon",
"learnMore": "Learn More",
"getInTouch": "Get in Touch"
},
"about": {
"presentationTitle": "Our School",
"presentationText": "Like the multi-faceted carambole fruit, our school nurtures curiosity, creativity, and the flourishing of every child.",
"founderName": "Mrs. Nguemo",
"founderRole": "Founder and Director",
"founderBio": "A passionate educator with over 15 years of experience, Mrs. Nguemo founded Les Caramboles to provide quality bilingual education.",
"pillarsTitle": "Our Educational Pillars",
"pillar_nature_title": "Nature",
"pillar_nature_desc": "Learning outdoors and respecting the environment.",
"pillar_art_title": "Art",
"pillar_art_desc": "Expressing creativity through drawing, music, and theatre.",
"pillar_discovery_title": "Discovery",
"pillar_discovery_desc": "Exploring the world with curiosity and wonder."
}
}
And here is the complete messages/fr.json file.
{
"nav": { "home": "Accueil", "about": "À propos", "contact": "Contact" },
"home": {
"heroTitle": "Bienvenue aux Caramboles",
"tagline": "Une école maternelle bilingue au cœur de Yaoundé",
"ctaLabel": "S'inscrire",
"aboutPreviewTitle": "À propos de nous",
"aboutPreviewText": "Découvrez notre philosophie éducative inspirée du fruit carambole.",
"contactPreviewTitle": "Nous contacter",
"contactPreviewText": "Carrefour Régie, Yaoundé, Cameroun",
"learnMore": "En savoir plus",
"getInTouch": "Nous écrire"
},
"about": {
"presentationTitle": "Notre école",
"presentationText": "Comme le fruit carambole aux multiples facettes, notre école cultive la curiosité, la créativité et l'épanouissement de chaque enfant.",
"founderName": "Mme Nguemo",
"founderRole": "Fondatrice et Directrice",
"founderBio": "Éducatrice passionnée avec plus de 15 ans d'expérience, Mme Nguemo a fondé Les Caramboles pour offrir une éducation bilingue de qualité.",
"pillarsTitle": "Nos piliers éducatifs",
"pillar_nature_title": "Nature",
"pillar_nature_desc": "Apprendre en plein air et respecter l'environnement.",
"pillar_art_title": "Art",
"pillar_art_desc": "Exprimer sa créativité à travers le dessin, la musique et le théâtre.",
"pillar_discovery_title": "Découverte",
"pillar_discovery_desc": "Explorer le monde avec curiosité et émerveillement."
}
}
Your About page now tells the full story of Les Caramboles across three polished sections. Next up, you will build the Contact page with the school's address and an embedded Google Map, then wire up a newsletter popup that saves subscriber emails to your MongoDB database.
Build the Contact Page and Newsletter Popup
Your About page tells the school's story, but parents still have no way to find the school's physical location or sign up for updates.
In this step, you will build the Contact page with school details and an embedded Google Maps iframe. You will also create a newsletter subscription system that saves emails to MongoDB through a Next.js API route.
In this step, get ready to:
- Build the Contact page with school details and a Google Maps embed.
- Create the newsletter API route with MongoDB.
- Build a newsletter popup component that auto-opens and saves subscriber emails.
Build the Contact page with school details and a map
The Contact page needs translated text for the school address, phone number, and email. Those translations must exist before the page component can render them.
- In messages/en.json, find the closing } of the "about" section.
- Add a comma after that closing brace, then paste the following contact section below it:
"contact": {
"title": "Contact Us",
"infoTitle": "Our Details",
"address": "Carrefour Regie, Yaounde, Cameroon",
"phone": "+237 6XX XXX XXX",
"email": "contact@lescaramboles.com"
}
- Do the same in messages/fr.json. Add a comma after the "about" section's closing brace, then paste the French contact translations below it:
"contact": {
"title": "Contactez-nous",
"infoTitle": "Nos coordonnées",
"address": "Carrefour Régie, Yaoundé, Cameroun",
"phone": "+237 6XX XXX XXX",
"email": "contact@lescaramboles.com"
}
What do these translations do?
Each key maps to a piece of text on the Contact page. The useTranslations('contact') hook in the page component will read from whichever language file matches the visitor's current locale.
- Save both messages/en.json and messages/fr.json.
Now you can build the page component that uses these translations. The Contact page will display the school details in a card and embed a Google Maps iframe showing the school's location.
- Open app/[locale]/contact/page.tsx and replace the entire file with the following code:
import { useTranslations } from 'next-intl';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export default function ContactPage() {
const t = useTranslations('contact');
return (
<div className="container py-16">
<h1 className="text-3xl font-bold mb-8">{t('title')}</h1>
<div className="grid md:grid-cols-2 gap-12">
{/* Left column: school contact details in a card */}
<Card>
<CardHeader><CardTitle>{t('infoTitle')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<p>{t('address')}</p>
<p>{t('phone')}</p>
<p>{t('email')}</p>
</CardContent>
</Card>
{/* Right column: embedded Google Map */}
<div className="rounded-xl overflow-hidden h-80">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3980.7!2d11.5!3d3.85!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x0%3A0x0!2zM8KwNTEnMDAuMCJOIDExwrAzMCcwMC4wIkU!5e0!3m2!1sen!2scm!4v1234567890"
width="100%" height="100%" style={{ border: 0 }} allowFullScreen loading="lazy"
referrerPolicy="no-referrer-when-downgrade" title="School location"
/>
</div>
</div>
</div>
);
}
What does this code do?
The page uses a two-column grid layout. The left column renders the school's address, phone, and email inside a Shadcn Card component. The right column embeds a Google Maps iframe.
On mobile screens, the md:grid-cols-2 class means the two columns stack vertically. The rounded-xl overflow-hidden classes give the map a clean rounded container.
How does the Google Maps embed work without an API key?
Google Maps has a built-in Share feature that generates an embeddable iframe URL for free. You navigate to a location on maps.google.com, click Share, then select Embed a map, and copy the iframe HTML.
This is different from the Maps Embed API, which requires a key. The Share embed feature is free with no usage limits.
- Save app/[locale]/contact/page.tsx.
- In your browser, navigate to localhost:3000/fr/contact.
- You should see the page heading "Contactez-nous", a card with the school address, phone, and email, and a Google Map on the right.
- Switch to localhost:3000/en/contact and confirm the text changes to "Contact Us" and "Our Details".
Page not rendering or showing an error?
- If you see a missing translation error, check that you added a comma between the "about" section and the new "contact" section in both JSON files.
- If the map does not load, check that the iframe tag's src URL is complete and starts with https://www.google.com/maps/embed.
- help me debug my Contact page
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files before moving on.
ⓧ I'd like to double check the full code
import { useTranslations } from 'next-intl';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export default function ContactPage() {
const t = useTranslations('contact');
return (
<div className="container py-16">
<h1 className="text-3xl font-bold mb-8">{t('title')}</h1>
<div className="grid md:grid-cols-2 gap-12">
<Card>
<CardHeader><CardTitle>{t('infoTitle')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<p>{t('address')}</p>
<p>{t('phone')}</p>
<p>{t('email')}</p>
</CardContent>
</Card>
<div className="rounded-xl overflow-hidden h-80">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3980.7!2d11.5!3d3.85!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x0%3A0x0!2zM8KwNTEnMDAuMCJOIDExwrAzMCcwMC4wIkU!5e0!3m2!1sen!2scm!4v1234567890"
width="100%" height="100%" style={{ border: 0 }} allowFullScreen loading="lazy"
referrerPolicy="no-referrer-when-downgrade" title="School location"
/>
</div>
</div>
</div>
);
}
Create the newsletter API route
You want parents to subscribe to school updates by entering their email. That email needs to travel from the browser to your MongoDB database. An API route handles that server-side logic.
- Create a new folder path app/api/newsletter/ inside your project's app/ directory.
- Create a new file called route.ts inside that folder and paste the following code:
import { NextRequest, NextResponse } from 'next/server';
import mongoose from 'mongoose';
import { connectToDatabase } from '@/lib/mongodb';
// Define the subscriber schema and model for newsletter emails
const SubscriberSchema = new mongoose.Schema({ email: { type: String, required: true, unique: true }, subscribedAt: { type: Date, default: Date.now } });
const Subscriber = mongoose.models.Subscriber || mongoose.model('Subscriber', SubscriberSchema);
export async function POST(req: NextRequest) {
const { email } = await req.json();
// Reject requests with no email or invalid format
if (!email || !email.includes('@')) return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
await connectToDatabase();
try {
await Subscriber.create({ email });
return NextResponse.json({ success: true });
} catch (err: any) {
// MongoDB duplicate key error code 11000 means already subscribed
if (err.code === 11000) return NextResponse.json({ error: 'Already subscribed' }, { status: 409 });
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
}
What does this route do?
The SubscriberSchema defines a Mongoose model with an email field (unique, so duplicates are rejected) and a subscribedAt timestamp that defaults to now.
The POST function reads the email from the request body, validates it, and saves it to MongoDB. If the email already exists, MongoDB returns error code 11000 and the route responds with a 409 status.
- Save app/api/newsletter/route.ts.
Before you build the popup, test that the API route works on its own. You can send a test request from your terminal.
- Make sure your dev server is running. Open a second terminal and send a test request by running this command:
curl -X POST http://localhost:3000/api/newsletter -H "Content-Type: application/json" -d '{"email":"test@example.com"'
- You should see {"success":true} in the terminal output.
- Verify the email was saved by opening mongosh in another terminal and running:
use les-caramboles
db.subscribers.find()
- You should see a document with email: 'test@example.com' and a subscribedAt timestamp.
Seeing a connection error or empty result?
- Make sure mongod is running in a separate terminal. The API route uses your existing connectToDatabase() function from lib/mongodb.ts to connect.
- If curl returns a 500 error, check the terminal running your dev server for the full error message.
- help me debug my newsletter API route
✔️ 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
import { NextRequest, NextResponse } from 'next/server';
import mongoose from 'mongoose';
import { connectToDatabase } from '@/lib/mongodb';
const SubscriberSchema = new mongoose.Schema({ email: { type: String, required: true, unique: true }, subscribedAt: { type: Date, default: Date.now } });
const Subscriber = mongoose.models.Subscriber || mongoose.model('Subscriber', SubscriberSchema);
export async function POST(req: NextRequest) {
const { email } = await req.json();
if (!email || !email.includes('@')) return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
await connectToDatabase();
try {
await Subscriber.create({ email });
return NextResponse.json({ success: true });
} catch (err: any) {
if (err.code === 11000) return NextResponse.json({ error: 'Already subscribed' }, { status: 409 });
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
}
Build the newsletter popup and wire it into the layout
The API route is ready to receive emails. Now you need a popup that appears after a visitor has been on the site for 10 seconds, collects their email, and sends it to your route.
- Create a new file called components/NewsletterPopup.tsx and paste the imports, state, and event handlers:
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
export function NewsletterPopup() {
// Track whether the dialog is open, the email input value, and submission state
const [open, setOpen] = useState(false);
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
// Auto-open the popup after 10 seconds, but only if not previously dismissed
useEffect(() => {
if (localStorage.getItem('newsletter_dismissed')) return;
const timer = setTimeout(() => setOpen(true), 10000);
return () => clearTimeout(timer);
}, []);
// Mark the popup as dismissed so it won't reappear on future page loads
const handleClose = () => {
localStorage.setItem('newsletter_dismissed', 'true');
setOpen(false);
};
What does this code do?
The useEffect checks localStorage for a newsletter_dismissed key. If the key exists, the popup never opens. If it does not exist, a 10-second timer starts.
The handleClose function writes the newsletter_dismissed key to localStorage before closing the dialog. This ensures the popup only appears once per visitor.
- Still in NewsletterPopup.tsx, add the submit handler and JSX return directly below the handleClose function:
// Send the email to the API route and close the popup after a brief delay
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await fetch('/api/newsletter', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
setSubmitted(true);
localStorage.setItem('newsletter_dismissed', 'true');
setTimeout(() => setOpen(false), 2000);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Stay in the loop!</DialogTitle>
<DialogDescription>Subscribe to our newsletter for school updates.</DialogDescription>
</DialogHeader>
{submitted ? (
<p className="text-center text-green-600 font-semibold py-4">Thank you for subscribing!</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input type="email" placeholder="your@email.com" value={email} onChange={(e) => setEmail(e.target.value)} required />
<Button type="submit">Subscribe</Button>
</form>
)}
</DialogContent>
</Dialog>
);
}
What does this code do?
The handleSubmit function sends a POST request to /api/newsletter with the email. After a successful response, it sets submitted to true so the form swaps to a thank-you message.
The Shadcn Dialog is controlled programmatically via the open prop. There is no trigger button because the popup opens automatically from the timer.
- Save components/NewsletterPopup.tsx.
✔️ Awesome, I've got everything!
Great. Confirm you have saved the file. The two chunks above combine into one complete component.
ⓧ I'd like to double check the full code
Scroll up and confirm both chunks are present in order. Chunk 1 starts with the imports and ends with the handleClose function. Chunk 2 starts with the handleSubmit function and ends with the closing } brace of the component.
The popup component exists, but it is not rendered anywhere yet. You need to add it to the locale layout so it appears on every page.
- Open app/[locale]/layout.tsx and find the import block at the top of the file.
- Find this line:
import { CTABanner } from '@/components/CTABanner';
- Add the following import directly below it:
import { NewsletterPopup } from '@/components/NewsletterPopup';
- Now scroll down to the JSX and find this line:
<Footer />
- Add the <NewsletterPopup /> component directly below it, so the line now looks like this:
<Footer />
<NewsletterPopup />
- Save app/[locale]/layout.tsx.
Why put the popup in the locale layout?
The locale layout wraps every page (Home, About, Contact). Placing the popup here means it works regardless of which page the visitor lands on first.
Before you test, do you think the popup will appear immediately or after a delay? What will prevent it from appearing again on the next page load?
- First, clear the test email from earlier so you can test a fresh submission. In your mongosh terminal, run:
db.subscribers.deleteMany({})
- Open your browser's developer tools (Cmd+Option+I on macOS or Ctrl+Shift+I on Windows).
- Go to the Application tab, expand Local Storage, select your site, and delete the newsletter_dismissed entry if it exists.
- Navigate to localhost:3000/fr/contact and wait 10 seconds.
A newsletter popup slides in with a heading, a short message, and an email input field.
- Enter an email address like parent@example.com and click Subscribe.
- You should see a "Thank you for subscribing!" message. The popup closes automatically after 2 seconds.
- Switch back to your mongosh terminal and verify the email was saved by running:
db.subscribers.find()
- You should see a document with email: 'parent@example.com' and a subscribedAt timestamp.
- Refresh the page in your browser. The popup should not reappear because localStorage now contains the newsletter_dismissed key.
Popup not appearing after 10 seconds?
- Check that you deleted the newsletter_dismissed entry from localStorage in your browser's developer tools. If this key exists, the popup will never open.
- Confirm that <NewsletterPopup /> appears in app/[locale]/layout.tsx inside the NextIntlClientProvider wrapper.
- Check the browser console for errors. A missing import or typo in the component file will prevent it from rendering.
- help me debug my newsletter popup
Your school website now has a Contact page with the school's location on a map and a newsletter system that captures subscriber emails in MongoDB. Next up, you will tackle the Secret Mission and add a gallery page with a lightbox.
Secret mission
Build a Gallery Page
The school has photos that showcase daily life and the learning environment. Can you build a responsive Gallery page with a masonry-style image grid and a full-screen lightbox with keyboard navigation?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local MongoDB database les-caramboles with newsletter subscriber data (stored at /usr/local/var/mongodb).
- Next.js development server (running in your terminal).
- MongoDB binaries installed at /usr/local/bin/ (from Project 1).
- The les-caramboles project folder.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with the next project in this series.
- Leave mongod and the Next.js dev server running.
- Your MongoDB data, project files, and installed packages stay in place and are ready for the next project.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and data persist on disk so you can pick up where you left off.
- Stop the Next.js development server by pressing Ctrl+C in the terminal where it is running.
- Stop MongoDB by running this command:
pkill -f mongod
- Your database and project files stay on disk. To resume later, restart mongod with the same --dbpath flag you used during setup, then run:
npm run dev
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your project files, MongoDB data, and the MongoDB binaries from your machine.
- Stop the Next.js development server by pressing Ctrl+C in the terminal where it is running.
- Stop MongoDB by running:
pkill -f mongod
- Delete the MongoDB data directory by running:
rm -rf /usr/local/var/mongodb
- Remove the MongoDB binaries that were copied to /usr/local/bin/ during Project 1 setup by running:
rm -f /usr/local/bin/mongod /usr/local/bin/mongos /usr/local/bin/mongosh
Check for extra MongoDB files
If you added any MongoDB-related entries to your shell configuration file (.zshrc or .bashrc), open the file and remove those lines as well.
- Delete the les-caramboles project folder. Navigate to the folder's parent directory, then run:
rm -rf les-caramboles
On Windows?
Replace the rm -rf commands above with rmdir /s /q followed by the folder path. Your MongoDB data and binary paths may also differ from the macOS/Linux paths listed here.
Nice Work!
Nice Work!
Nice work! You just built a full multi-page bilingual school website for Les Caramboles, complete with professional navigation, dark/light theming, and a newsletter subscription system backed by MongoDB.
You've learned how to:
- Build a multi-page bilingual website with Home, About, and Contact pages sharing a consistent header, footer, and CTA banner layout using Next.js 16 App Router file-system routing.
- Add dark/light theme support with next-themes and build a responsive layout with a desktop NavigationMenu, a mobile Sheet drawer, language switching, and social media links.
- Create a Contact page with an embedded Google Maps iframe and a newsletter subscription popup that saves emails to MongoDB via a Next.js API route.
- Secret Mission: Built a responsive Gallery page with a masonry-style image grid and a full-screen lightbox with keyboard navigation.
Ready to quiz yourself?