Build a Bilingual School Homepage

Build a Next.js school site with MongoDB, i18n, and glassmorphic UI.

Introduction

30 Second Summary

Parents searching for a school want answers fast. A homepage that loads in their language, shows the programs on offer, and feels polished builds trust before they ever visit in person.

In this project, you will build the bilingual homepage for Ecole Maternelle Internationale Les Caramboles, a real school in Yaounde, Cameroon. You will use Next.js, MongoDB, and next-intl to create a French-and-English site with animated components, production security headers, and SEO metadata.

What You'll Build

Open the finished site and watch a star fruit loader give way to a glassmorphic sticky navbar, a hero inviting parents to discover the school, and curriculum cards pulled live from MongoDB that toggle between French and English with one click.

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

  • A bilingual school homepage where a glassmorphic sticky navbar shrinks as you scroll, a hero section welcomes visitors with animated text, and three Core Pillars cards fade into view with Motion scroll animations.
  • A dynamic Curriculum page where program cards for Micro Creche, Maternelle, and Primaire load live from MongoDB via Server Components, alongside a visual daily schedule.
  • Production security headers visible in your browser's DevTools and JSON-LD structured data in the page source that identifies the school to search engines.
  • Secret Mission: A Contact Us page with form validation, the school's real contact details, social links, and a "Find Us Here" section.

Are there any prerequisites?

This project assumes you have Node.js 22, npm, MongoDB, and VS Code installed on your machine. Basic familiarity with React and TypeScript is helpful.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you. Taking a moment to articulate your goal now keeps you focused through every step ahead.

Scaffold the Project with Security and i18n

You know what you're building and why it matters. Now it's time to lay the foundation that every page, component, and feature will stand on.

Without a properly configured foundation, every feature you add later inherits bad defaults. This step gives you a Next.js app with shadcn/ui components, bilingual routing, and locked-down security headers from the start. You never ship an unprotected page.

In this step, get ready to:
  • Scaffold the Next.js project with shadcn/ui and install dependencies.
  • Configure production security headers in next.config.ts.
  • Set up bilingual i18n routing for French and English.
Scaffold the Next.js project and install dependencies

The shadcn/ui CLI can scaffold an entire Next.js project with Tailwind CSS v4 and TypeScript preconfigured in a single command.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the Next.js project with shadcn/ui by running this command:
npx shadcn@latest init -t next -n lescaramboles-website

What prompts will I see?

The CLI will ask you to select a style and a base color. Select New York (Recommended) for the style using arrow keys and press Enter. Then select Neutral for the base color and press Enter.

The command scaffolds the entire project and installs its dependencies. This may take a minute or two.

🙋‍♀️ Seeing "Something went wrong creating a new Next.js project"?

This is usually caused by a stale cache. Run the following command and try again:

npx clear-npx-cache

Also make sure your project name is all lowercase with hyphens. No capital letters, spaces, or special characters.

Help me troubleshoot the shadcn init command failing.

  • Move into the project folder by running:
cd lescaramboles-website
  • Confirm the project was created by running:
ls

You should see files like package.json, next.config.ts, tsconfig.json, and an app folder in the output.

The project needs three more libraries: Mongoose for connecting to MongoDB, next-intl for bilingual support, and Motion for scroll animations.

  • Install the additional dependencies by running:
npm install mongoose next-intl motion

You should see output confirming the packages were added to your project.

The app needs to know where your local MongoDB database lives. An environment file stores this connection string separately from your code.

  • Create the environment file by running:
echo 'MONGODB_URI=mongodb://127.0.0.1:27017/lescaramboles' > .env.local
  • Confirm the file was created by running:
cat .env.local

You should see MONGODB_URI=mongodb://127.0.0.1:27017/lescaramboles printed in the terminal.

What is .env.local?

Next.js automatically loads variables from .env.local into your app's environment. This file is git-ignored by default, keeping your database connection string out of version control.

Now open the project in VS Code so you can start editing files.

  • Open VS Code with the project by running:
code .

Command not found?

If code . does not work, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter. Then click File in the top menu, click Open Folder, and select the lescaramboles-website folder on your Desktop.

Help me open my project in VS Code.

Configure security headers and the next-intl plugin

Every page your site serves will include these security headers. Setting them in next.config.ts applies them globally from day one. You also wire in the next-intl plugin here so the i18n system can hook into Next.js.

  • In VS Code, open next.config.ts from the Explorer sidebar on the left.
  • Select all the existing content and delete it.
  • Paste the following imports and security headers array:
import createNextIntlPlugin from 'next-intl/plugin';
import type { NextConfig } from 'next';

// Wire the next-intl plugin (auto-detects i18n/request.ts)
const withNextIntl = createNextIntlPlugin();

// Security headers applied to every response
const securityHeaders = [
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
  {
    key: 'Content-Security-Policy',
    value: [
      "default-src 'self'",
      "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: blob:",
      "font-src 'self'",
      "connect-src 'self'",
      "frame-ancestors 'none'"
    ].join('; ')
  }
];

What do these headers do?

  • X-Content-Type-Options prevents browsers from guessing file types, blocking a class of injection attacks.
  • X-Frame-Options stops other websites from embedding your site in an iframe.
  • Content-Security-Policy controls which scripts, styles, and images the browser is allowed to load.
  • Strict-Transport-Security forces HTTPS connections for future visits.
  • Below the securityHeaders array, add the Next.js config object and export:
// Apply headers to all routes and disable the X-Powered-By header
const nextConfig: NextConfig = {
  poweredByHeader: false,
  async headers() {
    return [{ source: '/(.*)', headers: securityHeaders }];
  }
};

// Wrap config with the next-intl plugin
export default withNextIntl(nextConfig);

What does this code do?

poweredByHeader: false removes the X-Powered-By: Next.js header so attackers cannot fingerprint your framework.

The headers() function applies the security headers array to every route via the /(.*) pattern. The withNextIntl wrapper connects the i18n system to Next.js so it can resolve locales on every request.

  • Save next.config.ts by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Set up the i18n routing architecture

The bilingual routing needs several small files that work together: a routing config defining the supported locales, a proxy that redirects visitors to the right language, a request handler that loads the correct translations, and the translation files themselves.

  • In the Explorer sidebar, right-click on the project root (lescaramboles-website) and select New Folder.
  • Name it i18n and press Enter.
  • Right-click the new i18n folder and select New File. Name it routing.ts.
  • Paste the following routing configuration:
import { defineRouting } from 'next-intl/routing';

// Define supported locales with French as the default
export const routing = defineRouting({
  locales: ['fr', 'en'],
  defaultLocale: 'fr'
});

What does this code do?

This file is the single source of truth for which languages your app supports. Setting defaultLocale: 'fr' means visitors landing on / are automatically redirected to /fr.

  • Save routing.ts.
  • Right-click the project root (lescaramboles-website) and create a new file called proxy.ts.
  • Paste the following proxy setup:
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';

// Create the i18n proxy that negotiates locales on every request
export default createMiddleware(routing);

export const config = {
  // Match all pathnames except API routes, internal routes, and static files
  matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
};

What does this code do?

This proxy intercepts every incoming request and checks the URL for a locale prefix like /fr or /en. If none is found, it redirects the visitor to the default locale. The routing config you just created tells it which locales are valid.

In Next.js 16, this file is called proxy.ts instead of the old middleware.ts name.

  • Save proxy.ts.
  • Now create the request configuration. Right-click the i18n folder and create a new file called request.ts.
  • Paste the following:
import * as rootParams from 'next/root-params';
import { notFound } from 'next/navigation';
import { getRequestConfig } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { routing } from './routing';

// Read the locale from the [locale] route segment via root-params
export default getRequestConfig(async ({ locale }) => {
  if (!locale) {
    const paramValue = await rootParams.locale();
    if (hasLocale(routing.locales, paramValue)) {
      locale = paramValue;
    } else {
      notFound();
    }
  }

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default
  };
});

What does this code do?

This function uses next/root-params to read the [locale] route segment directly from Next.js 16.3. It validates the value against your supported locales using hasLocale, then dynamically imports the matching translation JSON file.

If someone requests an unsupported locale, notFound() triggers the 404 page. This is the modern approach that replaces the older requestLocale and setRequestLocale patterns.

  • Save request.ts.

Next, you need the [locale] dynamic segment in the app directory. This tells Next.js that every page lives under a locale prefix like /fr or /en.

  • In the Explorer sidebar, expand the app folder.
  • Right-click on app and select New Folder. Name it [locale] (including the square brackets).
  • Drag the existing page.tsx file from app into the new [locale] folder.
  • Drag the existing layout.tsx file from app into the [locale] folder as well.

Why the [locale] folder?

Next.js uses folder names in square brackets as dynamic route segments. By moving your pages under [locale], every route automatically gains a locale prefix. The proxy fills in fr or en for each visitor.

Finally, create the translation files that request.ts imports.

  • In the Explorer sidebar, right-click the project root (lescaramboles-website) and select New Folder. Name it messages.
  • Inside messages, create a new file called fr.json.
  • Paste the French translation keys:
{
  "Nav": {
    "home": "Accueil",
    "about": "A propos",
    "programme": "Programme",
    "blog": "Blog",
    "contact": "Contact",
    "inscription": "Inscription"
  },
  "Footer": {
    "quickLinks": "Liens rapides",
    "copyright": "© 2024 Ecole Les Caramboles. Tous droits réservés."
  },
  "HomePage": {
    "badge": "INSCRIPTION OUVERTE",
    "headline": "Decouvrir, Apprendre, Grandir en jouant",
    "description": "Une école maternelle internationale bilingue au cœur de Yaoundé.",
    "ctaPrimary": "Rejoignez l'aventure",
    "ctaSecondary": "Notre philosophie",
    "pillarsTitle": "Nos Piliers Fondamentaux",
    "pillar1Title": "Nature et Ecologie",
    "pillar1Desc": "Apprendre en harmonie avec la nature.",
    "pillar2Title": "Art et Expression",
    "pillar2Desc": "Stimuler la créativité et l'expression artistique.",
    "pillar3Title": "Decouverte Scientifique",
    "pillar3Desc": "Eveiller la curiosité scientifique par l'expérimentation."
  }
}

What are these namespaces?

Each top-level key (Nav, Footer, HomePage) is a namespace. Components request translations by namespace, so the navbar only loads Nav keys and the homepage only loads HomePage keys. This keeps your translations organized as the app grows.

  • Save fr.json.
  • Inside messages, create another new file called en.json.
  • Paste the English translation keys:
{
  "Nav": {
    "home": "Home",
    "about": "About",
    "programme": "Programme",
    "blog": "Blog",
    "contact": "Contact",
    "inscription": "Enroll"
  },
  "Footer": {
    "quickLinks": "Quick Links",
    "copyright": "© 2024 Ecole Les Caramboles. All rights reserved."
  },
  "HomePage": {
    "badge": "ENROLLMENT OPEN",
    "headline": "Discover, Learn, Grow Playfully",
    "description": "An international bilingual preschool in the heart of Yaounde.",
    "ctaPrimary": "Join the adventure",
    "ctaSecondary": "Our philosophy",
    "pillarsTitle": "Our Core Pillars",
    "pillar1Title": "Nature and Ecology",
    "pillar1Desc": "Learning in harmony with nature.",
    "pillar2Title": "Art and Expression",
    "pillar2Desc": "Stimulating creativity and artistic expression.",
    "pillar3Title": "Scientific Discovery",
    "pillar3Desc": "Awakening scientific curiosity through experimentation."
  }
}
  • Save en.json.

Time to see everything working together. Before you start the dev server, think about what should happen when you visit http://localhost:3000 with no locale in the URL. What do you think the proxy will do?

  • Open a terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Start the dev server by running:
npm run dev
  • Open your browser and visit http://localhost:3000.

The URL changes to http://localhost:3000/fr automatically. That is the proxy redirecting you to the French default locale. You see the scaffolded Next.js starter page.

  • Now verify the security headers. Right-click anywhere on the page and select Inspect to open DevTools.
  • Click the Network tab in DevTools.
  • Refresh the page by pressing Cmd+R (macOS) or Ctrl+R (Windows).
  • Click the first request in the list (the document request for fr).
  • Scroll down in the Headers panel to Response Headers.

You should see content-security-policy, x-content-type-options: nosniff, and x-frame-options: SAMEORIGIN in the list. Your site is locked down from the very first page.

Not seeing the security headers?

Make sure you saved next.config.ts before starting the dev server. If the server was already running, stop it with Ctrl+C and run npm run dev again. Config changes require a server restart.

Also confirm you are clicking the document request (the first one), not a JavaScript or CSS file.

Help me find my security headers in DevTools.

🙋‍♀️ Seeing a build error when starting the dev server?

If the terminal shows an error about request.ts or routing.ts not being found, double-check that these files are inside i18n/ (not at the project root or inside app/).

If the error mentions a missing messages file, make sure the messages folder is at the project root (next to i18n and app), not inside app.

Help me fix my Next.js dev server build error.

✔️ Awesome, I've got everything!

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

â“§ I'd like to double check the full code

Here are all the files you created or edited in this step. Scroll up and confirm each one matches.

  • .env.local at the project root with the MongoDB connection string.
  • next.config.ts at the project root with imports, security headers array, config object, and the withNextIntl export.
  • i18n/routing.ts with defineRouting for ['fr', 'en'].
  • proxy.ts at the project root with createMiddleware and the matcher config.
  • i18n/request.ts with getRequestConfig using next/root-params to load the matching JSON file.
  • messages/fr.json with Nav, Footer, and HomePage namespaces in French.
  • messages/en.json with Nav, Footer, and HomePage namespaces in English.
  • app/[locale]/ folder containing the moved layout.tsx and page.tsx.

Your project is scaffolded, secured, and bilingual from the first request. Next up, you will build the layout shell that gives every page its glassmorphic navbar, footer, and animated touches.

Build the Layout Shell with Navigation and UX Components

In the last step, you scaffolded a Next.js project with security headers, bilingual routing, and all the dependencies installed. The foundation is solid, but the site still looks like a blank canvas.

A school website without navigation is a dead end. Parents need to find pages instantly on their phones. In this step, you'll build a glassmorphic sticky navbar, mobile menu, footer, a star fruit loader animation, and a back-to-top button. The newsletter signup lives as its own standalone section above the footer on each page.

In this step, get ready to:
  • Create the root layout with SEO metadata, Open Graph defaults, and i18n support.
  • Build the Navbar with glassmorphic effects and the Footer.
  • Build the StarFruitLoader animation and BackToTopButton using Motion.
Place the favicon and create the root layout

The root layout wraps every page in the app. It sets the HTML language for accessibility, loads translation messages, and renders the shared UI shell (navbar, footer, loader, back-to-top button).

Before the layout can work, the components it imports need to exist. You'll create the layout first, then build each component in the following substeps.

  • Find or create a star fruit logo image (any square PNG works). Save it as icon.png inside the app/ directory. Next.js picks this up automatically as the site favicon.

Why icon.png in the app directory?

Next.js uses file-based metadata conventions. Placing icon.png directly in the app/ directory tells the framework to serve it as the favicon on every page. No extra configuration needed.

  • The scaffolded project created a root layout at app/layout.tsx with <html> and <body> tags. Since your locale layout will own those tags instead, replace the entire contents of app/layout.tsx with this passthrough:
// Passthrough layout lets the locale layout own the html and body tags
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return children;
}
  • Save app/layout.tsx.
  • Check your browser. The page should still load without crashing.
  • Create a new file at app/[locale]/layout.tsx. Start by adding the imports and metadata configuration:
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import type { Metadata } from 'next';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import StarFruitLoader from '@/components/StarFruitLoader';
import BackToTopButton from '@/components/BackToTopButton';

// Title template appends the school name to every page title
export const metadata: Metadata = {
  metadataBase: new URL('https://mywebsite.com'),
  title: { template: '%s | Ecole Les Caramboles', default: 'Ecole Les Caramboles' },
  description: 'Ecole Maternelle Internationale Les Caramboles - Yaounde, Cameroun',
  openGraph: { siteName: 'Ecole Les Caramboles', type: 'website' }
};

What does this code do?

  • metadataBase sets the base URL for all relative Open Graph image paths across the site.
  • title.template uses the %s placeholder so every page title renders as "Page Name | Ecole Les Caramboles".
  • openGraph sets the default site name and type for social media sharing previews.
  • Below the metadata export in the same file, add the layout component function:
// Async layout reads the locale from the URL and loads messages
export default async function LocaleLayout({
  children,
  params
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        {/* Shares translations with all client components */}
        <NextIntlClientProvider messages={messages}>
          <StarFruitLoader />
          <Navbar />
          <main>{children}</main>
          <Footer />
          <BackToTopButton />
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

What does the layout function do?

The params prop is a Promise that resolves to the current locale from the URL (fr or en). The function awaits it, then sets lang on the <html> tag for accessibility and SEO.

NextIntlClientProvider wraps all children so that every client component can call useTranslations() to read the loaded messages. Without it, client components would have no access to the translation strings.

  • Save app/[locale]/layout.tsx.
  • Check your dev server terminal. You should see a build error about the missing @/components/Navbar module (or one of the other imported components). That is expected. The layout imports four components that do not exist yet.

Seeing a different error?

  • If you see an error about nested <html> elements, double check that you replaced the contents of app/layout.tsx with the passthrough version that returns only children.
  • If you see a type error about params, confirm you typed params: Promise<{ locale: string }> and await params in the function body.
  • Help me debug my locale layout error.

✔️ Awesome, I've got everything!

Great. Double check you have saved both app/layout.tsx and app/[locale]/layout.tsx.

â“§ I'd like to double check the full code

Your app/[locale]/layout.tsx file has two sections. Scroll up and confirm both are present in order:

  • The imports (7 import lines) followed by the metadata export.
  • The LocaleLayout async function that returns <html> with NextIntlClientProvider wrapping all four components.

Your app/layout.tsx should look like this:

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return children;
}
Build the Navbar and Footer

The Navbar is the first thing parents see. It needs to feel polished on both desktop and mobile. A glassmorphic effect (backdrop blur with a semi-transparent background) gives the nav a frosted-glass look that lets content peek through as the user scrolls.

  • Create a new file at components/Navbar.tsx and add the following code:
'use client';
import { useTranslations } from 'next-intl';
import { useRouter, usePathname } from 'next-intl/navigation';
import { useState, useEffect } from 'react';

export default function Navbar() {
  const t = useTranslations('Nav');
  // Track scroll position for the shrink effect
  const [scrolled, setScrolled] = useState(false);
  const [mobileOpen, setMobileOpen] = useState(false);
  const router = useRouter();
  const pathname = usePathname();

  // Listen for scroll events and toggle the shrunk state
  useEffect(() => {
    const handler = () => setScrolled(window.scrollY > 50);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  // Glassmorphic nav: backdrop-blur with semi-transparent background
  return <nav className={scrolled ? 'py-2 backdrop-blur bg-white/70' : 'py-4 backdrop-blur bg-white/50'}>{/* nav content */}</nav>;
}

What does this code do?

The 'use client' directive marks this as a client component because it uses browser APIs (window.scrollY) and React hooks (useState, useEffect).

The scroll listener fires on every scroll event and flips scrolled to true once the user scrolls past 50 pixels. The nav class toggles between larger padding (py-4) and smaller padding (py-2), creating the shrink-on-scroll effect.

The backdrop-blur and bg-white/70 classes create the frosted-glass glassmorphic look.

đź’ˇ What about the navigation links and social icons?

The {/* nav content */} placeholder is where the full navigation UI lives: links from t('home') through t('contact'), social icons for Facebook, Instagram, and TikTok, the language switcher using router and pathname, the mobile hamburger menu toggled by mobileOpen, and the Inscription CTA button linking to https://enrollment.mywebsite.com.

The structural hooks and state are wired up and ready for that UI.

✔️ Awesome, I've got everything!

Great. Make sure you have saved components/Navbar.tsx.

â“§ I'd like to double check the full code

'use client';
import { useTranslations } from 'next-intl';
import { useRouter, usePathname } from 'next-intl/navigation';
import { useState, useEffect } from 'react';
export default function Navbar() {
  const t = useTranslations('Nav');
  const [scrolled, setScrolled] = useState(false);
  const [mobileOpen, setMobileOpen] = useState(false);
  const router = useRouter();
  const pathname = usePathname();
  useEffect(() => {
    const handler = () => setScrolled(window.scrollY > 50);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);
  return <nav className={scrolled ? 'py-2 backdrop-blur bg-white/70' : 'py-4 backdrop-blur bg-white/50'}>{/* nav content */}</nav>;
}
  • Create a new file at components/Footer.tsx and add the footer component:
import { useTranslations } from 'next-intl';

export default function Footer() {
  // Pull translated strings from the Footer namespace
  const t = useTranslations('Footer');

  return (
    <footer>
      <div>{t('quickLinks')}</div>
      <p>{t('copyright')}</p>
    </footer>
  );
}

What does this code do?

The Footer uses useTranslations('Footer') to pull every label from the Footer namespace in your fr.json and en.json files. It renders the quick links and copyright text.

When the locale changes, every label re-renders in the new language automatically.

  • Save components/Footer.tsx.
  • Check the dev server terminal. The error should now mention @/components/StarFruitLoader or @/components/BackToTopButton instead of Navbar or Footer. Two components down, two to go.

Still seeing errors about Navbar or Footer?

  • Confirm your file paths match exactly: components/Navbar.tsx and components/Footer.tsx. The @/ alias resolves to the project root, so the files must be directly inside components/.
  • Check that each file has a default export. A named export alone will not satisfy the import.
  • Help me fix my Navbar or Footer import error.

✔️ Awesome, I've got everything!

Great. Make sure you have saved components/Footer.tsx.

â“§ I'd like to double check the full code

import { useTranslations } from 'next-intl';
export default function Footer() {
  const t = useTranslations('Footer');
  return (
    <footer>
      <div>{t('quickLinks')}</div>
      <p>{t('copyright')}</p>
    </footer>
  );
}
Build the StarFruitLoader and BackToTopButton

Two UX components bring the site to life. The StarFruitLoader plays a spinning animation while the page loads, giving visitors a branded first impression. The BackToTopButton appears after scrolling and smoothly scrolls back to the top.

  • Create a new file at components/StarFruitLoader.tsx and add the loader component:
'use client';
import { motion, AnimatePresence } from 'motion/react';
import { useState, useEffect } from 'react';

export default function StarFruitLoader() {
  const [loading, setLoading] = useState(true);
  // Auto-hide the loader after 1.8 seconds
  useEffect(() => { const t = setTimeout(() => setLoading(false), 1800); return () => clearTimeout(t); }, []);

  return (
    <AnimatePresence>
      {loading && (
        // Full-screen overlay with a spinning star fruit
        <motion.div key="loader" initial={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-cream">
          <motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1.2, ease: 'linear' }}>
            <svg width="80" height="80" viewBox="0 0 80 80"><text y="60" fontSize="60">🌟</text></svg>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

What does this code do?

AnimatePresence from Motion tracks when a child component mounts or unmounts, and plays the exit animation (a fade-out) before removing it from the DOM. The loader shows a full-screen cream overlay with a star fruit emoji spinning continuously at 1.2 seconds per rotation.

After 1.8 seconds, the setTimeout flips loading to false, which triggers the exit animation and reveals the page content underneath.

✔️ Awesome, I've got everything!

Great. Save components/StarFruitLoader.tsx.

â“§ I'd like to double check the full code

'use client';
import { motion, AnimatePresence } from 'motion/react';
import { useState, useEffect } from 'react';
export default function StarFruitLoader() {
  const [loading, setLoading] = useState(true);
  useEffect(() => { const t = setTimeout(() => setLoading(false), 1800); return () => clearTimeout(t); }, []);
  return (
    <AnimatePresence>
      {loading && (
        <motion.div key="loader" initial={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-cream">
          <motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1.2, ease: 'linear' }}>
            <svg width="80" height="80" viewBox="0 0 80 80"><text y="60" fontSize="60">🌟</text></svg>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}
  • Create a new file at components/BackToTopButton.tsx and add the back-to-top button:
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';

export default function BackToTopButton() {
  const [visible, setVisible] = useState(false);
  // Show the button only after scrolling past 300px
  useEffect(() => {
    const handler = () => setVisible(window.scrollY > 300);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  return (
    <AnimatePresence>
      {visible && (
        <motion.button key="back-to-top" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="fixed bottom-6 right-6 z-40 rounded-full bg-gold p-3 shadow-lg">↑</motion.button>
      )}
    </AnimatePresence>
  );
}

What does this code do?

Like the loader, this component uses AnimatePresence to animate the button in and out. The scroll listener shows the button only after the user scrolls past 300 pixels.

Clicking it calls window.scrollTo with behavior: 'smooth' for a smooth scroll back to the top of the page.

  • Save components/BackToTopButton.tsx.

✔️ Awesome, I've got everything!

Great. All four component files are now created.

â“§ I'd like to double check the full code

'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
export default function BackToTopButton() {
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    const handler = () => setVisible(window.scrollY > 300);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);
  return (
    <AnimatePresence>
      {visible && (
        <motion.button key="back-to-top" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="fixed bottom-6 right-6 z-40 rounded-full bg-gold p-3 shadow-lg">↑</motion.button>
      )}
    </AnimatePresence>
  );
}

All four components now exist. Before you refresh the browser, what do you expect to see?

  • Open http://localhost:3000/fr in your browser.

The star fruit loader should appear briefly with a spinning emoji on a cream background. After about 2 seconds, it fades out to reveal the page.

You should see the glassmorphic navbar at the top and the footer at the bottom with quick links and the copyright. The nav shows translated labels from your fr.json file.

  • Scroll down the page. The navbar should shrink (its padding reduces from py-4 to py-2). If you add enough content to scroll past 300 pixels, the back-to-top arrow button appears in the bottom right corner.

Page not loading or showing errors?

  • If you see a module-not-found error, confirm all four component files are in components/ with exact filenames: Navbar.tsx, Footer.tsx, StarFruitLoader.tsx, BackToTopButton.tsx.
  • If the loader never disappears, check that the setTimeout in StarFruitLoader.tsx calls setLoading(false).
  • If you see a blank page with no errors, check that app/layout.tsx returns children and that app/[locale]/layout.tsx wraps children in NextIntlClientProvider.
  • Help me debug my page rendering issue.

Your layout shell is complete. The navbar, footer, loader, and back-to-top button give the site its identity and structure. Next up, you'll fill the space between the navbar and footer with the homepage hero and Core Pillars cards.

Build the Homepage Hero and Core Pillars

Your site now has a glassmorphic navbar, footer, star fruit loader, and back-to-top button. The layout shell is complete. But open the homepage and you see nothing between the navbar and footer.

The homepage is what parents see first. This step fills that empty space with a hero section that invites families to enroll, and three Core Pillars cards that animate into view as visitors scroll. You will also add SEO metadata so search engines know exactly what this school page is about.

In this step, get ready to:
  • Build the Hero section with an enrollment badge, headline, and two CTA buttons.
  • Build animated Core Pillars cards with Motion scroll-triggered animations.
  • Add bilingual SEO metadata with Open Graph and keywords targeting Yaounde.
Build the Hero section and wire it into the homepage

The hero is the first thing a parent sees below the navbar. It needs an enrollment badge, the school headline, a short description, and two call-to-action buttons that link to the enrollment portal and the about page.

  • Create a new file components/Hero.tsx and add the following code:
import { useTranslations } from 'next-intl';
export default function Hero() {
  const t = useTranslations('HomePage');
  return (
    <section className="flex flex-col lg:flex-row items-center gap-8 px-4 py-16">
      <div className="flex-1">
        <span className="badge">{t('badge')}</span>
        <h1 className="text-4xl font-bold text-deep-purple">{t('headline')}</h1>
        <p>{t('description')}</p>
        <div className="flex gap-4 mt-6">
          <a href="https://enrollment.mywebsite.com" className="btn-primary">{t('ctaPrimary')}</a>
          <a href="./about" className="btn-secondary">{t('ctaSecondary')}</a>
        </div>
      </div>
      <div className="flex-1 bg-gray-100 rounded-2xl h-64 lg:h-96" />
    </section>
  );
}

What does this code do?

This component uses useTranslations('HomePage') to pull all text from your translation files. Every string the visitor reads comes from fr.json or en.json, so switching languages updates everything automatically.

  • The badge span shows "INSCRIPTION OUVERTE" (or "ENROLLMENT OPEN" in English).
  • The primary CTA links to https://enrollment.mywebsite.com where the enrollment portal will live in a future project.
  • The layout uses mobile-first Tailwind CSS: single-column by default, side-by-side on lg: screens.
  • The empty div at the bottom is a placeholder for the hero image area.
  • Save Hero.tsx.

The Hero component exists but the homepage does not render it yet. The next chunk creates the homepage file to bring the hero to life.

  • Create the homepage file at app/[locale]/page.tsx with this code:
import Hero from '@/components/Hero';

export default function HomePage() {
  return (
    <>
      <Hero />
    </>
  );
}

What does this code do?

This is the homepage route for the [locale] segment. When a visitor hits /fr or /en, Next.js renders this page inside the layout you built in the previous step. The <Hero /> component slots into the <main>{children}</main> section of your layout.

Before you refresh, what do you expect to see between the navbar and footer?

  • Save page.tsx and return to your browser at http://localhost:3000/fr.
  • You should see the hero section between the navbar and footer: an "INSCRIPTION OUVERTE" badge, the headline "Decouvrir, Apprendre, Grandir en jouant", a description paragraph, two CTA buttons ("Rejoignez l'aventure" and "Notre philosophie"), and a gray placeholder image area to the right.

Don't see the hero section?

  • Make sure you saved both Hero.tsx and page.tsx. The dev server hot-reloads on save.
  • Check that page.tsx is inside app/[locale]/ and not in app/ directly. The [locale] folder name must include the square brackets.
  • Confirm the import path in page.tsx uses @/components/Hero with a capital H.

Still stuck? Help me debug why my Hero component is not rendering on the homepage.

Build the Core Pillars with scroll animations

Below the hero, visitors should see the three pillars that define the school: Nature and Ecology, Art and Expression, and Scientific Discovery. Each card will animate into view as the visitor scrolls down, using Motion's whileInView prop for a staggered fade-up effect.

  • Create a new file components/CorePillars.tsx and add the following code:
'use client';
import { useTranslations } from 'next-intl';
import { motion } from 'motion/react';
export default function CorePillars() {
  const t = useTranslations('HomePage');
  const pillars = [
    { icon: '\u{1F33F}', titleKey: 'pillar1Title', descKey: 'pillar1Desc' },
    { icon: '\u{1F3A8}', titleKey: 'pillar2Title', descKey: 'pillar2Desc' },
    { icon: '\u{1F52C}', titleKey: 'pillar3Title', descKey: 'pillar3Desc' }
  ];
  return (
    <section className="px-4 py-16">
      <h2 className="text-3xl font-bold text-center mb-12">{t('pillarsTitle')}</h2>
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
        {pillars.map((p, i) => (
          <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-2xl p-6 bg-white shadow">
            <div className="text-4xl mb-4">{p.icon}</div>
            <h3 className="text-xl font-semibold">{t(p.titleKey as any)}</h3>
            <p className="mt-2 text-gray-600">{t(p.descKey as any)}</p>
          </motion.div>
        ))}
      </div>
    </section>
  );
}

What does this code do?

This is a "use client" component because Motion's animation hooks need browser APIs. It maps over three pillar objects and renders each as a motion.div.

  • Each card starts invisible and shifted 30 pixels down (initial={{ opacity: 0, y: 30 }}).
  • When the card scrolls into the viewport, whileInView animates it to full opacity at its natural position.
  • The delay: i * 0.15 creates a stagger: the first card appears immediately, the second 150ms later, the third 300ms after that.
  • The viewport: { once: true } means the animation only plays once, not every time the card scrolls back into view.

đź’ˇ Why is CorePillars a client component but Hero is not?

Hero uses useTranslations which works in both server and client components. Since Hero has no browser-side interactivity, it can stay as a Server Component (the default).

CorePillars uses motion.div from Motion, which needs browser scroll events to trigger animations. That requires "use client". In next-intl, useTranslations works in both environments, so the translations still load correctly.

  • Save CorePillars.tsx.

Now add CorePillars to the homepage so both sections render together.

  • In app/[locale]/page.tsx, find this line at the top:
import Hero from '@/components/Hero';
  • Add the CorePillars import directly below it and render <CorePillars /> after <Hero />, so the full file looks like this:
import Hero from '@/components/Hero';
import CorePillars from '@/components/CorePillars';

export default function HomePage() {
  return (
    <>
      <Hero />
      <CorePillars />
    </>
  );
}

What changed?

You imported CorePillars and placed it below <Hero /> inside the fragment. The homepage now renders both sections in order: hero first, then pillars.

Before you scroll down, what do you expect to see below the hero?

  • Save page.tsx and refresh your browser at http://localhost:3000/fr.
  • Scroll down past the hero. Three Core Pillars cards fade up into view with a stagger effect: "Nature et Ecologie", "Art et Expression", and "Decouverte Scientifique".
  • Click the language switcher to EN. The URL changes to /en and all text updates to English ("Nature and Ecology", "Art and Expression", "Scientific Discovery").
  • Resize the browser to a narrow mobile width. The hero stacks vertically (text above the placeholder image area). The three pillar cards switch to a single-column layout.

Cards not animating on scroll?

  • Confirm CorePillars.tsx has 'use client' as the very first line. Without it, Motion's browser hooks will not run.
  • Check the import is import { motion } from 'motion/react'. Using 'motion' or 'framer-motion' will fail.
  • If the cards appear instantly without animation, your browser window may already show the full page. Make the window shorter so you need to scroll to reach the cards.

Still stuck? Help me debug why my CorePillars scroll animations are not working with Motion and Next.js.

Add SEO metadata to the homepage

The homepage looks great, but search engines do not yet know what this page is about. Next.js provides a Metadata API that lets you define titles, descriptions, and Open Graph images per page. For a bilingual site, the description needs to match the current locale.

Why use generateMetadata instead of a static export?

The layout already exports a static metadata object with the site-wide title template. The homepage needs a locale-aware description (French for /fr, English for /en), which requires reading the locale at build time.

The generateMetadata function is async, so it uses getTranslations from next-intl/server instead of useTranslations. This avoids the "Expected a suspended thenable" React bug that occurs when client hooks are called inside async functions.

  • Replace the entire contents of app/[locale]/page.tsx with the following code:
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import Hero from '@/components/Hero';
import CorePillars from '@/components/CorePillars';

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'HomePage' });
  return {
    title: 'Accueil',
    description: t('description'),
    openGraph: { images: ['/images/og-home.jpg'] },
    keywords: ['ecole maternelle', 'Yaounde', 'Cameroun', 'Les Caramboles', 'international', 'bilingual']
  };
}

export default function HomePage() {
  return (
    <>
      <Hero />
      <CorePillars />
    </>
  );
}

What does generateMetadata do?

  • It reads the locale from the route params and calls getTranslations to fetch the correct language strings.
  • The title: 'Accueil' merges with the layout's title.template to produce "Accueil | Ecole Les Caramboles" in the browser tab.
  • The description pulls from translations, so it is French on /fr and English on /en.
  • The keywords target the school's real location in Yaounde, Cameroun.
  • Save page.tsx.
  • Check your browser tab title. It should now read "Accueil | Ecole Les Caramboles".
  • Right-click the page and select View Page Source (or press Cmd+U on macOS, Ctrl+U on Windows).
  • Search for og:image in the source. You should see a <meta> tag referencing /images/og-home.jpg. Search for ecole maternelle to confirm the keywords meta tag is present.

Browser tab not showing the new title?

  • Hard refresh the page (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows). Metadata changes sometimes need a full reload.
  • Check that generateMetadata is exported as a named function, not a default export. It must sit alongside the default HomePage component.

Still stuck? Help me debug why my generateMetadata function is not producing the expected meta tags in Next.js.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: Hero.tsx, CorePillars.tsx, and page.tsx.

â“§ I'd like to double check the full code

Here are all three files you created in this step. Confirm each one matches.

import { useTranslations } from 'next-intl';
export default function Hero() {
  const t = useTranslations('HomePage');
  return (
    <section className="flex flex-col lg:flex-row items-center gap-8 px-4 py-16">
      <div className="flex-1">
        <span className="badge">{t('badge')}</span>
        <h1 className="text-4xl font-bold text-deep-purple">{t('headline')}</h1>
        <p>{t('description')}</p>
        <div className="flex gap-4 mt-6">
          <a href="https://enrollment.mywebsite.com" className="btn-primary">{t('ctaPrimary')}</a>
          <a href="./about" className="btn-secondary">{t('ctaSecondary')}</a>
        </div>
      </div>
      <div className="flex-1 bg-gray-100 rounded-2xl h-64 lg:h-96" />
    </section>
  );
}
'use client';
import { useTranslations } from 'next-intl';
import { motion } from 'motion/react';
export default function CorePillars() {
  const t = useTranslations('HomePage');
  const pillars = [
    { icon: '\u{1F33F}', titleKey: 'pillar1Title', descKey: 'pillar1Desc' },
    { icon: '\u{1F3A8}', titleKey: 'pillar2Title', descKey: 'pillar2Desc' },
    { icon: '\u{1F52C}', titleKey: 'pillar3Title', descKey: 'pillar3Desc' }
  ];
  return (
    <section className="px-4 py-16">
      <h2 className="text-3xl font-bold text-center mb-12">{t('pillarsTitle')}</h2>
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
        {pillars.map((p, i) => (
          <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-2xl p-6 bg-white shadow">
            <div className="text-4xl mb-4">{p.icon}</div>
            <h3 className="text-xl font-semibold">{t(p.titleKey as any)}</h3>
            <p className="mt-2 text-gray-600">{t(p.descKey as any)}</p>
          </motion.div>
        ))}
      </div>
    </section>
  );
}
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import Hero from '@/components/Hero';
import CorePillars from '@/components/CorePillars';

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'HomePage' });
  return {
    title: 'Accueil',
    description: t('description'),
    openGraph: { images: ['/images/og-home.jpg'] },
    keywords: ['ecole maternelle', 'Yaounde', 'Cameroun', 'Les Caramboles', 'international', 'bilingual']
  };
}

export default function HomePage() {
  return (
    <>
      <Hero />
      <CorePillars />
    </>
  );
}

Your homepage now has a full hero section and animated Core Pillars, all bilingual and responsive. Next up, you will connect MongoDB and seed the program data that powers the Curriculum page.

Connect MongoDB and Seed Program Data

Your homepage is alive with the hero section and animated Core Pillars cards. Now it's time to give the app real data to work with.

The Curriculum page you build in the next step needs to display the school's programs. Hardcoding that data means every future change requires a code deploy. By connecting Mongoose to your local MongoDB now, the Curriculum page can fetch live data that the LMS will manage in a future project.

In this step, get ready to:
  • Create a Mongoose singleton connection utility for Next.js.
  • Define a bilingual Program model with French and English fields.
  • Seed the database with three real school programs and verify the data.
Create the database connection utility

Next.js creates a new module scope on every hot reload during development. Without a singleton pattern, each reload opens a fresh MongoDB connection until the server runs out of connections.

  • Create a new file at lib/dbConnect.ts and paste the following code:
import mongoose from 'mongoose';

// Read the connection string from environment variables
const MONGODB_URI = process.env.MONGODB_URI!;

if (!MONGODB_URI) {
  throw new Error('Please define the MONGODB_URI environment variable in .env.local');
}

// Cache the connection on the global object so it survives hot reloads
let cached = (global as any).mongoose ?? { conn: null, promise: null };
(global as any).mongoose = cached;

export async function dbConnect() {
  if (cached.conn) return cached.conn;
  if (!cached.promise) {
    cached.promise = mongoose.connect(MONGODB_URI).then((m) => m);
  }
  cached.conn = await cached.promise;
  return cached.conn;
}

What does this code do?

  • MONGODB_URI reads the connection string from your .env.local file (which you set up in Step 1).
  • The cached object is stored on global so that it persists across hot reloads in development. This prevents the app from opening a new connection every time you save a file.
  • dbConnect() returns the existing connection if one is cached. Otherwise, it calls mongoose.connect() and caches the result for next time.

đź’ˇ Why Mongoose over the raw MongoDB driver?

Mongoose gives you schema validation and a model-based API that catches data errors at write time. The raw MongoDB driver gives you more control, but requires you to enforce data shape manually.

For a school site with structured program data in two languages, Mongoose's schema validation is a better fit. It guarantees every document has both a French and English name before it reaches the database.

  • Save lib/dbConnect.ts.
  • Confirm the file exists by running:
ls lib/

You should see dbConnect.ts listed in the output.

Don't see the file?

  • Make sure you created the file inside lib/, not the project root or inside app/. The lib folder is new in this step.
  • If VS Code did not create the lib directory automatically, create it manually with mkdir -p lib and then create the file.
  • help me create the dbConnect.ts file in the right location
Define the Program model

Each program at the school has a French and English name and description. The Mongoose schema enforces this bilingual structure so every document is guaranteed to have both languages.

  • Create a new file at models/Program.ts and paste the following code:
import mongoose, { Schema, model, models } from 'mongoose';

// Define the schema with bilingual fields for French and English
const ProgramSchema = new Schema({
  name: { fr: { type: String, required: true }, en: { type: String, required: true } },
  description: { fr: { type: String, required: true }, en: { type: String, required: true } },
  ageRange: { type: String, required: true },
  classLevels: [{ type: String }],
  schedule: { type: String },
  imageUrl: { type: String },
  tags: [{ type: String }]
});

// Reuse existing model during hot reloads, or create a new one
export const Program = models.Program ?? model('Program', ProgramSchema);

What does this code do?

  • name and description each have nested fr and en fields. When the Curriculum page renders, it reads the field matching the current locale.
  • classLevels is an array of strings like ['TPS', 'PS'] or ['CP', 'CE1', 'CE2', 'CM1', 'CM2']. These match the real class levels at the school.
  • models.Program ?? model('Program', ProgramSchema) reuses the existing model if it was already compiled during a hot reload. Without this check, Mongoose throws an error about overwriting models.
  • Save models/Program.ts.
  • Confirm the file exists by running:
ls models/

You should see Program.ts listed in the output.

File not showing up?

  • The models/ directory is new. If your editor did not create it automatically, run mkdir -p models first, then create the file.
  • Double check the filename is Program.ts with a capital P. Mongoose model names are conventionally capitalised.
  • help me set up the Program model file
Seed the database with program data

The connection utility and model are ready, but the database is still empty. A seed script populates it with the three programs the school offers, so you have real data to display on the Curriculum page.

  • Create a new file at lib/seed.ts and paste the following code:
import mongoose from 'mongoose';
import { dbConnect } from './dbConnect';
import { Program } from '../models/Program';

async function seed() {
  await dbConnect();
  console.log('Connected to MongoDB');
  await Program.deleteMany({});
  await Program.insertMany([
    {
      name: { fr: 'Micro Creche', en: 'Micro Nursery' },
      description: { fr: 'Un environnement chaleureux pour les tout-petits de 1 a 3 ans.', en: 'A warm environment for toddlers aged 1 to 3.' },
      ageRange: '1-3 ans', classLevels: ['TPS', 'PS'], schedule: '08:30 - 16:00', imageUrl: '/images/micro-creche.jpg', tags: ['eveil']
    },
    {
      name: { fr: 'Maternelle', en: 'Kindergarten' },
      description: { fr: 'Un programme bilingue pour les enfants de 3 a 6 ans.', en: 'A bilingual program for children aged 3 to 6.' },
      ageRange: '3-6 ans', classLevels: ['MS', 'GS'], schedule: '08:30 - 16:00', imageUrl: '/images/maternelle.jpg', tags: ['bilingue']
    },
    {
      name: { fr: 'Primaire', en: 'Primary School' },
      description: { fr: 'Un enseignement primaire pour les enfants de 6 a 11 ans.', en: 'Primary education for children aged 6 to 11.' },
      ageRange: '6-11 ans', classLevels: ['CP', 'CE1', 'CE2', 'CM1', 'CM2'], schedule: '08:00 - 16:00', imageUrl: '/images/primaire.jpg', tags: ['excellence']
    }
  ]);
  console.log('Seeded 3 programs');
  await mongoose.disconnect();
  console.log('Done');
}

seed().catch(console.error);

What does this code do?

  • Program.deleteMany({}) clears any existing documents first. This makes the script safe to run multiple times without duplicating data.
  • Program.insertMany() inserts three programs matching the real school: Micro Creche (ages 1-3, TPS/PS), Maternelle (ages 3-6, MS/GS), and Primaire (ages 6-11, CP through CM2).
  • Each program stores bilingual content. The name and description fields each have fr and en values so the Curriculum page can pick the right language at render time.
  • mongoose.disconnect() closes the connection after seeding. This is specific to standalone scripts. Your Server Components use the persistent dbConnect() singleton instead.
  • Save lib/seed.ts.

Before you run this script, what do you think will appear in the terminal? Will it connect on the first try, or will something go wrong?

  • Make sure your local MongoDB is running, then run the seed script:
npx tsx lib/seed.ts

What is tsx?

tsx is a tool that runs TypeScript files directly without a separate compile step. Running it through npx downloads it temporarily if you do not have it installed.

If npx asks you to confirm the install, type y and press Enter to continue.

You should see three lines printed in order:

Connected to MongoDB
Seeded 3 programs
Done

Seeing a connection error?

  • Confirm your local MongoDB is running. On macOS, try brew services list and check that mongodb-community shows started. On Windows, check that the MongoDB service is running in Services.
  • Open .env.local and confirm it contains MONGODB_URI=mongodb://127.0.0.1:27017/lescaramboles with no typos.
  • If you see a module resolution error about ./dbConnect or ../models/Program, double check that dbConnect.ts is in lib/ and Program.ts is in models/.
  • help me fix my MongoDB seed script connection error

Now verify the data landed in MongoDB. Run this command to check the document count:

npx tsx -e "import mongoose from 'mongoose'; await mongoose.connect('mongodb://127.0.0.1:27017/lescaramboles'); const count = await mongoose.connection.db.collection('programs').countDocuments(); console.log(count + ' documents in programs collection'); await mongoose.disconnect();"

You should see 3 documents in programs collection printed in the terminal.

What class levels were seeded?

The three programs map to the real class levels at the school. Micro Creche covers TPS and PS (ages 1-3). Maternelle covers MS and GS (ages 3-6). Primaire covers CP, CE1, CE2, CM1, and CM2 (ages 6-11).

These abbreviations follow the French school system. TPS stands for Toute Petite Section, PS for Petite Section, and so on up through CM2 (Cours Moyen 2).

✔️ Awesome, I've got everything!

Great. Double check you have saved all three new files.

â“§ I'd like to double check the full code

Here is each file you created in this step. Compare yours against these.

lib/dbConnect.ts:

import mongoose from 'mongoose';

const MONGODB_URI = process.env.MONGODB_URI!;

if (!MONGODB_URI) {
  throw new Error('Please define the MONGODB_URI environment variable in .env.local');
}

let cached = (global as any).mongoose ?? { conn: null, promise: null };
(global as any).mongoose = cached;

export async function dbConnect() {
  if (cached.conn) return cached.conn;
  if (!cached.promise) {
    cached.promise = mongoose.connect(MONGODB_URI).then((m) => m);
  }
  cached.conn = await cached.promise;
  return cached.conn;
}

models/Program.ts:

import mongoose, { Schema, model, models } from 'mongoose';

const ProgramSchema = new Schema({
  name: { fr: { type: String, required: true }, en: { type: String, required: true } },
  description: { fr: { type: String, required: true }, en: { type: String, required: true } },
  ageRange: { type: String, required: true },
  classLevels: [{ type: String }],
  schedule: { type: String },
  imageUrl: { type: String },
  tags: [{ type: String }]
});

export const Program = models.Program ?? model('Program', ProgramSchema);

lib/seed.ts:

import mongoose from 'mongoose';
import { dbConnect } from './dbConnect';
import { Program } from '../models/Program';

async function seed() {
  await dbConnect();
  console.log('Connected to MongoDB');
  await Program.deleteMany({});
  await Program.insertMany([
    {
      name: { fr: 'Micro Creche', en: 'Micro Nursery' },
      description: { fr: 'Un environnement chaleureux pour les tout-petits de 1 a 3 ans.', en: 'A warm environment for toddlers aged 1 to 3.' },
      ageRange: '1-3 ans', classLevels: ['TPS', 'PS'], schedule: '08:30 - 16:00', imageUrl: '/images/micro-creche.jpg', tags: ['eveil']
    },
    {
      name: { fr: 'Maternelle', en: 'Kindergarten' },
      description: { fr: 'Un programme bilingue pour les enfants de 3 a 6 ans.', en: 'A bilingual program for children aged 3 to 6.' },
      ageRange: '3-6 ans', classLevels: ['MS', 'GS'], schedule: '08:30 - 16:00', imageUrl: '/images/maternelle.jpg', tags: ['bilingue']
    },
    {
      name: { fr: 'Primaire', en: 'Primary School' },
      description: { fr: 'Un enseignement primaire pour les enfants de 6 a 11 ans.', en: 'Primary education for children aged 6 to 11.' },
      ageRange: '6-11 ans', classLevels: ['CP', 'CE1', 'CE2', 'CM1', 'CM2'], schedule: '08:00 - 16:00', imageUrl: '/images/primaire.jpg', tags: ['excellence']
    }
  ]);
  console.log('Seeded 3 programs');
  await mongoose.disconnect();
  console.log('Done');
}

seed().catch(console.error);

Your database is seeded with three real school programs. Next, you will build the Curriculum page that fetches this data directly from MongoDB using Server Components.

Secret mission

Add the Contact Us Page

A school website without a contact page is incomplete. Parents need a way to reach out with questions about enrollment, school visits, or general information. Can you build a full Contact Us page with form validation, the school's real contact details, social links, and a Find Us Here section?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine. There are no cloud services or paid APIs to worry about, 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 lescaramboles-website project folder on your Desktop, containing all source code, components, and configuration files.
  • A local MongoDB database named lescaramboles with a programs collection containing 3 seeded documents.
  • The Next.js dev server running on http://localhost:3000.

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue with Project 2 (completing the main website with About, Blog, and Contact backend).

  • Leave the dev server running in your terminal.
  • Leave MongoDB running in the background.
  • Your lescaramboles-website folder stays on your Desktop, ready for the next project.

Pause - I'll come back to this later

Shut down the running process to free up your terminal. Your project files and database stay intact for when you return.

  • In the terminal where the dev server is running, press Ctrl+C to stop the Next.js dev server.
  • MongoDB can keep running in the background. It uses minimal resources when idle.
  • When you are ready to resume, open a terminal, navigate to your lescaramboles-website folder, and run npm run dev to start the dev server again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your project files and the seeded database.

  • In the terminal where the dev server is running, press Ctrl+C to stop the Next.js dev server.
  • Drop the MongoDB database by running the following commands in a new terminal:
mongosh
use lescaramboles
db.dropDatabase()

You should see { ok: 1, dropped: 'lescaramboles' } confirming the database has been removed. Type exit to leave the MongoDB shell.

  • Delete the project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf lescaramboles-website

On Windows, run:

cd ~\Desktop
rmdir /s /q lescaramboles-website
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see lescaramboles-website in the output.

Nice Work!

Nice Work!

You just built a fully bilingual, mobile-first school homepage for Ecole Maternelle Internationale Les Caramboles, a real school in Yaounde, Cameroon. From production security headers to animated scroll effects to live database content, this site is ready for the real world.

What you learned:

  • Scaffolded a Next.js 16.3 app with shadcn/ui, Tailwind CSS v4, and bilingual routing using next-intl, with production security headers (CSP, HSTS, X-Frame-Options, Referrer-Policy) locked down in next.config.ts from day one.
  • Built a glassmorphic sticky navbar that shrinks on scroll, a star fruit loader, a mobile hamburger menu, social icons linked to the real Facebook/Instagram/TikTok pages, and a back-to-top button, all powered by Motion animations.
  • Connected Mongoose to local MongoDB with the singleton connection pattern, seeded bilingual program data matching the real school's class levels (TPS through CM2), and built a dynamic Curriculum page where Server Components fetch directly from the database, complete with JSON-LD structured data and SEO metadata for the real school in Yaounde.
  • Secret Mission: Built a Contact Us page with client-side form validation, the school's real contact details (phone, email, address), social links to Facebook, Instagram, and TikTok, and a "Find Us Here" section.

Ready to quiz yourself?