Build a Bilingual School Landing Page
Build a Next.js 16 school site with i18n, MongoDB, dark mode, and SEO.
Introduction
30 Second Summary
When parents visit a school website, they want to find information quickly in their own language. A site that only speaks one language or looks unprofessional loses trust before the first click.
In this project, you will build a polished, bilingual landing page for Les Caramboles school that switches between French and English, pulls content from a local database, and is fully optimized for search engines and social sharing.
What You'll Build
You'll have a mobile-first school homepage running locally that you can demo in two languages, scroll smoothly back to the top with one tap, and watch content update live from a MongoDB database.
By the end of this project, you'll have:
- A bilingual homepage at /fr and /en with a language switcher that flips all page content between French and English instantly.
- A polished user experience with a page loader, fade-in animations powered by Motion, a Back to top button, and social media icons linking to Instagram, Facebook, and TikTok.
- Dynamic school content pulled from a local MongoDB database with full SEO metadata (Open Graph tags for Facebook and TikTok, JSON-LD structured data) and security headers configured for production.
- Secret Mission: Build a responsive mobile hamburger navigation with social media links and accessible drawer animations.
Are there any prerequisites?
This project assumes you already have Node.js 22.22.3 and VS Code installed on your machine. You will install MongoDB and all other dependencies during Step 1.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters. You are building the main landing page for Les Caramboles school. This is the foundation of a multi-app platform that will eventually include an enrollment system and a backoffice dashboard. Getting the infrastructure right here (i18n, theming, database, SEO, security) means every future page and app inherits a solid base.
Scaffold the Project and Install MongoDB
To build the Les Caramboles school landing page, you need two foundational pieces in place: a Next.js project with TypeScript and Tailwind CSS, and a local MongoDB database to store school content.
This step gets both running so every future step can build on a solid foundation.
In this step, get ready to:
- Scaffold a Next.js 16 project with shadcn/ui pre-configured.
- Install MongoDB Community Edition locally via tarball.
- Install the project's additional dependencies and verify everything runs.
Scaffold Next.js 16 with shadcn/ui
The shadcn/ui CLI can scaffold an entire Next.js project in a single command. It sets up TypeScript, Tailwind CSS, and the shadcn component system all at once.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the project by running this command:
npx shadcn@latest init -t next
What prompts will you see?
The CLI is interactive. It will ask you a series of questions to configure your project.
- When it asks for a project name, type les-caramboles and press Enter.
- When it asks which preset to use, select the first (default) option and press Enter.
- For any remaining prompts, accept the defaults by pressing Enter.
The CLI downloads the Next.js 16 template, installs dependencies, and configures shadcn/ui. This takes a minute or two.
- Once the scaffold finishes, move into the project folder by running:
cd les-caramboles
- Confirm the project files exist by running:
ls
You should see files like package.json, next.config.ts, tsconfig.json, and folders like app/ and node_modules/.
Command not found or scaffold fails?
- Make sure Node.js is installed. Run node --version and confirm you see v22 or higher.
- If npx is not found, reinstall Node.js from the official site.
Help me troubleshoot the shadcn scaffold command failing on my machine.
Install MongoDB Community Edition via tarball
MongoDB stores data as flexible documents instead of rigid table rows. You will install it manually from the official tarball since this project does not use Homebrew.
- Download the MongoDB 8.3.7 Community Edition tarball for macOS x64 by opening this URL in your browser:
- In the Version dropdown, select 8.3.7.
- In the Platform dropdown, select macOS x64.
- In the Package dropdown, select tgz.
- Click Download.
Apple Silicon Mac?
If you have an M1, M2, M3, or M4 Mac, select macOS ARM 64 as the platform instead. The rest of the steps are the same.
- Once the download finishes, open a new terminal tab and extract the tarball by running:
cd ~/Downloads
tar -zxvf mongodb-macos-x86_64-8.3.7.tgz
What does this command do?
- tar -zxvf decompresses and extracts the .tgz archive into a folder.
- The extracted folder contains a bin/ directory with the MongoDB binaries (mongod, mongos).
- Copy the MongoDB binaries to your system PATH by running:
sudo cp ~/Downloads/mongodb-macos-x86_64-8.3.7/bin/* /usr/local/bin/
Your terminal will ask for your Mac password. Type it and press Enter. The cursor will not move while you type.
- Create the data directory and log directory that MongoDB needs by running:
sudo mkdir -p /usr/local/var/mongodb
sudo mkdir -p /usr/local/var/log/mongodb
- Start MongoDB by running:
mongod --dbpath /usr/local/var/mongodb --logpath /usr/local/var/log/mongodb/mongo.log --fork
What does this command do?
- --dbpath tells MongoDB where to store its data files.
- --logpath tells MongoDB where to write its log output.
- --fork runs MongoDB in the background so you can keep using the terminal.
Before you verify, do you expect the version command to work? What version number should it print?
- Confirm MongoDB is installed correctly by running:
mongod --version
You should see output that includes db version v8.3.7 (or similar). This confirms the binaries are in your PATH and ready to use.
Seeing 'command not found'?
- Make sure the sudo cp command earlier completed without errors. Re-run it if needed.
- Confirm the binary exists at /usr/local/bin/mongod by running ls /usr/local/bin/mongod.
- If you downloaded the ARM 64 version but copied the wrong folder name, adjust the cp path to match the extracted folder.
Help me get mongod working on my Mac after a tarball install.
Install project dependencies
The scaffold gave you Next.js, React, and Tailwind. Now you need three more packages for internationalization, animations, and database access.
- Switch back to the terminal tab where your project lives (the les-caramboles folder on your Desktop).
- Install the runtime dependencies by running:
npm install next-intl motion mongoose
What are these packages?
- next-intl handles internationalization (switching between French and English).
- motion (formerly Framer Motion) provides page animations and transitions.
- mongoose is an ODM (Object Document Mapper) that connects your app to MongoDB.
- Install the TypeScript types for Mongoose as a dev dependency by running:
npm install -D @types/mongoose
Before you start the dev server, what do you expect to see in the browser?
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000.
You should see the default Next.js starter page. This confirms your scaffold is working and all dependencies are installed correctly.
Dev server won't start?
- Make sure you are inside the les-caramboles folder. Run pwd and confirm it ends with /les-caramboles.
- If you see a port conflict, stop other processes on port 3000 or run npm run dev -- -p 3001 to use a different port.
- If packages are missing, delete node_modules and run npm install again.
Help me get my Next.js dev server running.
Your development environment is ready. You have a Next.js 16 project with shadcn/ui, a local MongoDB instance, and all the packages you need. Next up, you will build the actual landing page and see it come to life in the browser.
Build the Mobile-First Home Page
Your development environment is running and ready. Now it is time to see something real on screen.
In this step, you will replace the default starter page with a custom landing page for Les Caramboles school. The page includes a hero section, an about section, and contact information, all styled for mobile screens first and then expanded for larger devices.
In this step, get ready to:
- Add shadcn/ui Button and Card components to your project.
- Create a home page with Hero, About, and Contact sections.
- Style the page mobile-first with responsive Tailwind breakpoints.
Create the page structure
Before you can use shadcn/ui components in your page, you need to add them to the project. The shadcn CLI copies the component source code directly into your project so you own it and can customize it.
- Add the Button and Card components by running this command in your terminal:
npx shadcn@latest add button card
What does this command do?
The npx shadcn@latest add command downloads the component source files into a components/ui/ folder in your project. You can import and use them like any local component.
Now you have the building blocks for the page layout. The current app/page.tsx shows the default shadcn starter content. You will replace it entirely.
- Open app/page.tsx in VS Code.
- Select all the existing content and delete it.
- Paste in the following code to create the Hero and About sections:
import { Card, CardContent } from "@/components/ui/card";
export default function Home() {
return (
<main className="min-h-screen px-4 py-8">
{/* Hero Section */}
<section className="flex flex-col items-center text-center py-12">
<h1 className="text-3xl font-bold">Les Caramboles</h1>
<p className="mt-4 text-lg text-muted-foreground">
A nurturing bilingual school where children grow, play, and
learn together.
</p>
</section>
{/* About Section */}
<section className="py-12">
<h2 className="text-2xl font-semibold">About Us</h2>
<p className="mt-4 text-muted-foreground">
Les Caramboles is a warm, inclusive school dedicated to
nurturing young minds through creative exploration and
bilingual education in French and English.
</p>
</section>
</main>
);
}
What does this code do?
- The Card and CardContent imports bring in the shadcn/ui components you just added. You will use them in the Contact section next.
- The Hero section centers the school name and tagline on screen.
- The About section introduces the school's mission below the hero.
- Save app/page.tsx.
- Switch to your browser and check localhost:3000. You should see the school name "Les Caramboles" centered at the top with the tagline below it, and an "About Us" heading further down the page.
Don't see the new page?
- Make sure you saved the file. Unsaved changes will not trigger a reload.
- Check that the dev server is still running in your terminal. If it stopped, run npm run dev again.
- Confirm you replaced ALL the old content. Any leftover JSX from the starter page will cause errors.
Still stuck? Help me debug why my page.tsx changes are not showing in the browser.
Now add the Contact section below the About section. This uses the Card component to give the contact details a contained, structured look.
- In app/page.tsx, find the closing </section> tag of the About section.
- Paste the following code directly below it, before the closing </main> tag:
{/* Contact Section */}
<section className="py-12">
<h2 className="text-2xl font-semibold">Contact</h2>
<Card className="mt-4">
<CardContent className="p-6">
<p className="text-muted-foreground">
Email: contact@lescaramboles.com
</p>
<p className="mt-2 text-muted-foreground">
Phone: +596 696 00 00 00
</p>
</CardContent>
</Card>
</section>
What does this code do?
The Card wraps the contact details in a bordered container with consistent padding. CardContent is the main body area of the card. This gives the contact info visual separation from the rest of the page.
- Save app/page.tsx.
- Check your browser. You should now see a "Contact" heading with the email and phone number inside a bordered card below the About section.
Seeing a compile error?
- Make sure the Contact section code sits between the About section's closing </section> and the closing </main> tag. Nesting it inside the About section causes a layout error.
- Confirm the import at the top of the file still includes both Card and CardContent.
Need help? Help me fix a compile error after adding the Contact section to my page.
Style mobile-first with Tailwind
The page structure is in place, but it looks the same on all screen sizes. A mobile-first approach means starting with styles for the smallest screen and layering on adjustments for larger devices using Tailwind CSS responsive prefixes.
- In app/page.tsx, find the opening <main> tag.
- Find this line:
<main className="min-h-screen px-4 py-8">
- Replace it with this version that adds tablet and desktop padding:
<main className="min-h-screen px-4 py-8 md:px-8 lg:px-16">
- Next, find the Hero section's opening <section> tag:
<section className="flex flex-col items-center text-center py-12">
- Replace it with added vertical padding for larger screens:
<section className="flex flex-col items-center text-center py-12 md:py-20 lg:py-28">
- Find the Hero <h1> tag:
<h1 className="text-3xl font-bold">Les Caramboles</h1>
- Replace it with responsive text sizing:
<h1 className="text-3xl font-bold md:text-4xl lg:text-5xl">Les Caramboles</h1>
- Find the Hero paragraph tag:
<p className="mt-4 text-lg text-muted-foreground">
- Replace it with responsive text size and a max-width for readability on large screens:
<p className="mt-4 text-lg text-muted-foreground md:text-xl lg:max-w-2xl">
- Finally, find the Card in the Contact section:
<Card className="mt-4">
- Replace it with a max-width constraint for tablet and desktop:
<Card className="mt-4 md:max-w-md">
- Save app/page.tsx.
- In your browser, open DevTools and toggle device emulation to a mobile viewport (around 375px wide). The page should show a tight single-column layout with compact padding.
- Switch to a desktop viewport. The heading grows larger, padding increases, and the contact card narrows to a contained width.
What are md: and lg: prefixes?
Tailwind's responsive prefixes apply styles only at or above a breakpoint. md: targets screens 768px and wider. lg: targets 1024px and wider. Styles without a prefix apply to all screens, starting from mobile.
🙋♀️ Layout not changing between sizes?
- Double-check you replaced the entire className string, not just appended to it. The old and new classes should not both be present.
- Make sure DevTools device emulation is active. Resizing the browser window alone may not shrink below 768px.
Still seeing issues? Help me debug why my Tailwind responsive breakpoints are not working.
Add the enrollment CTA
The page needs a clear call-to-action so families can start the enrollment process. For now, the button links to the school's existing Jotform. In a future project, this will link to a custom enrollment system.
- In app/page.tsx, add the Button import at the top of the file. Find this line:
import { Card, CardContent } from "@/components/ui/card";
- Replace it with both imports:
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
- Now find the closing </p> tag of the Hero tagline (the paragraph about "grow, play, and learn together").
- Paste the following Button code directly below that </p> tag, still inside the Hero <section>:
<Button asChild className="mt-8" size="lg">
<a
href="https://form.jotform.com/230986513095563"
target="_blank"
rel="noopener noreferrer"
>
Enroll Now
</a>
</Button>
What does this code do?
- The asChild prop tells the Button component to render as its child element (the <a> tag) instead of a <button>. This gives you button styling on a link.
- The target="_blank" opens the enrollment form in a new tab so visitors do not leave the school site.
- The rel="noopener noreferrer" is a security best practice for external links opened in new tabs.
- Save app/page.tsx.
- Before you check the browser, what do you expect to see below the tagline?
Check your browser. You should see a large "Enroll Now" button centered below the tagline. Click it and confirm it opens the Jotform enrollment page in a new tab.
Button not appearing?
- Confirm the Button import line is at the top of the file. A missing import causes a compile error, not a blank spot.
- Make sure the Button code is inside the Hero <section> tag, after the </p> and before </section>.
Need help? Help me figure out why my Enroll Now button is not showing up on the page.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/page.tsx and your browser shows all three sections plus the Enroll Now button.
ⓧ I'd like to double check the full code
Here is the complete app/page.tsx at this point. Make sure yours matches:
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export default function Home() {
return (
<main className="min-h-screen px-4 py-8 md:px-8 lg:px-16">
{/* Hero Section */}
<section className="flex flex-col items-center text-center py-12 md:py-20 lg:py-28">
<h1 className="text-3xl font-bold md:text-4xl lg:text-5xl">Les Caramboles</h1>
<p className="mt-4 text-lg text-muted-foreground md:text-xl lg:max-w-2xl">
A nurturing bilingual school where children grow, play, and
learn together.
</p>
<Button asChild className="mt-8" size="lg">
<a
href="https://form.jotform.com/230986513095563"
target="_blank"
rel="noopener noreferrer"
>
Enroll Now
</a>
</Button>
</section>
{/* About Section */}
<section className="py-12">
<h2 className="text-2xl font-semibold">About Us</h2>
<p className="mt-4 text-muted-foreground">
Les Caramboles is a warm, inclusive school dedicated to
nurturing young minds through creative exploration and
bilingual education in French and English.
</p>
</section>
{/* Contact Section */}
<section className="py-12">
<h2 className="text-2xl font-semibold">Contact</h2>
<Card className="mt-4 md:max-w-md">
<CardContent className="p-6">
<p className="text-muted-foreground">
Email: contact@lescaramboles.com
</p>
<p className="mt-2 text-muted-foreground">
Phone: +596 696 00 00 00
</p>
</CardContent>
</Card>
</section>
</main>
);
}
Your school landing page is live and responsive. Next up, you will add bilingual support so the page can switch between French and English.
Add Internationalization
Your static home page only speaks one language. Les Caramboles serves both French and English-speaking families, and duplicating every page for each language would be a maintenance nightmare.
The next-intl library solves this with locale-based routing. Instead of copying pages, you define translations in JSON files and let the URL (/fr or /en) determine which language to render.
In this step, get ready to:
- Configure next-intl routing and the proxy for locale detection.
- Restructure the app directory to support locale-based URLs.
- Create French and English translation files with a language switcher.
Configure next-intl routing and proxy
Before next-intl can route requests to the right language, it needs three configuration files: a routing definition, a proxy that intercepts requests and detects the locale, and a request config that loads the correct messages.
- Create the src/i18n/ directory by running these commands in your terminal:
mkdir -p src/i18n
- Create the routing configuration file at src/i18n/routing.ts and paste this code:
import {defineRouting} from 'next-intl/routing';
// Define the supported locales and 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 your supported languages. defineRouting tells next-intl that your app supports French and English, with French as the default.
Every other i18n file imports from here, so you only define your locales once.
- Save src/i18n/routing.ts.
- Now create the proxy file at the project root. Create proxy.ts in the same folder as next.config.ts and paste this code:
import createMiddleware from 'next-intl/middleware';
import {routing} from './src/i18n/routing';
// Intercept requests and redirect to the matched locale
export default createMiddleware(routing);
export const config = {
// Match all pathnames except API, internal, and static files
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
};
What does this code do?
The proxy intercepts every incoming request and checks the URL for a locale prefix. If someone visits / without a locale, the proxy redirects them to /fr (your default).
The matcher pattern excludes API routes, Next.js internals, and static files (anything with a dot like favicon.ico) from locale processing.
💡 Why is this file called proxy.ts?
Next.js 16 renamed middleware.ts to proxy.ts. The next-intl library already supports this new name.
- Save proxy.ts.
- Create the request configuration at src/i18n/request.ts and paste this code:
import {getRequestConfig} from 'next-intl/server';
import {hasLocale} from 'next-intl';
import {routing} from './routing';
// Load messages for the current locale on each request
export default getRequestConfig(async ({locale}) => {
if (!hasLocale(routing.locales, locale)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default
};
});
What does this code do?
This runs once per request. It receives the locale from the proxy, validates it against your routing config, then dynamically imports the matching JSON translation file.
The messages object it returns becomes available to every component via useTranslations().
- Save src/i18n/request.ts.
- Now update next.config.ts to wire the next-intl plugin. Open next.config.ts and replace its contents with:
import {NextConfig} from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
const nextConfig: NextConfig = {};
// Wire next-intl into the Next.js build
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);
What does this code do?
The createNextIntlPlugin function automatically finds your src/i18n/request.ts file and links it to the Next.js build pipeline. Every page now has access to the locale and messages at render time.
- Save next.config.ts.
- Stop the dev server with Ctrl+C and restart it by running:
npm run dev
The server will likely show an error because you have not created the translation files or restructured the app directory yet. That is expected and you will fix it next.
Seeing a module not found error?
If the error mentions messages/fr.json not found, that confirms the config is wired correctly. You will create those files in substep 3.3.
If you see a different error about next-intl/middleware or next-intl/routing, double-check that next-intl is in your package.json dependencies. It was installed in Step 1.
Help me fix a next-intl configuration error.
Restructure the app directory for locale routing
For locale-based URLs to work, all your page content must live inside an app/[locale]/ directory. The [locale] segment is a dynamic route that captures fr or en from the URL.
- Create the locale directory by running:
mkdir -p "app/[locale]"
- Move your existing page into the new directory by running:
mv app/page.tsx "app/[locale]/page.tsx"
- Confirm the move worked by running:
ls app/[locale]/
You should see page.tsx listed in the output.
- Now create the locale layout. Create app/[locale]/layout.tsx and paste this code:
import {setRequestLocale} from 'next-intl/server';
import {hasLocale} from 'next-intl';
import {notFound} from 'next/navigation';
import {routing} from '@/i18n/routing';
import {NextIntlClientProvider} from 'next-intl';
// Generate static pages for each locale at build time
export function generateStaticParams() {
return routing.locales.map((locale) => ({locale}));
}
type Props = {
children: React.ReactNode;
params: Promise<{locale: string}>;
};
What does this code do?
The generateStaticParams function tells Next.js to pre-render pages for both fr and en at build time.
The imports bring in locale validation (hasLocale), static rendering support (setRequestLocale), and the client provider that makes translations available to interactive components.
- Add the layout component below the type definition in the same file:
export default async function LocaleLayout({children, params}: Props) {
const {locale} = await params;
// Validate the locale from the URL
if (!hasLocale(routing.locales, locale)) {
notFound();
}
// Enable static rendering for this locale
setRequestLocale(locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
What does this code do?
The layout reads the locale from the URL params, validates it against your routing config, and returns a 404 if someone navigates to an unsupported locale like /de.
It sets lang={locale} on the <html> tag so browsers and screen readers know the page language. The NextIntlClientProvider wrapper makes translations available to client components.
- Save app/[locale]/layout.tsx.
- Now update the root layout at app/layout.tsx to be a minimal wrapper. Replace its contents with:
// Root layout - delegates rendering to the locale layout
export default function RootLayout({
children
}: {
children: React.ReactNode;
}) {
return children;
}
Why is the root layout so minimal?
The root layout just passes children through. All the real work (setting the <html> tag, applying the locale, wrapping with providers) happens in app/[locale]/layout.tsx. This prevents duplicate <html> tags.
- Save app/layout.tsx.
Seeing a hydration error about html tags?
If you see an error about nested <html> or <body> tags, make sure your root app/layout.tsx does NOT contain any <html> or <body> elements. Those now live only in app/[locale]/layout.tsx.
Help me fix a hydration error in my locale layout.
Create translation files and the language switcher
With the routing in place, you need the actual translation content. You will create JSON files for each language and build a switcher component so users can toggle between French and English.
- Create the messages directory by running:
mkdir messages
- Create messages/fr.json with the French translations:
{
"HomePage": {
"heroTitle": "Les Caramboles",
"heroTagline": "Une école où chaque enfant s'épanouit",
"ctaLabel": "Inscription",
"aboutTitle": "À Propos",
"aboutText": "Les Caramboles est une école dédiée à l'épanouissement de chaque enfant dans un environnement bienveillant et stimulant.",
"contactTitle": "Contact",
"contactText": "Contactez-nous pour en savoir plus sur notre école et nos programmes."
}
}
- Save messages/fr.json.
- Create messages/en.json with the English translations:
{
"HomePage": {
"heroTitle": "Les Caramboles",
"heroTagline": "A school where every child thrives",
"ctaLabel": "Enroll Now",
"aboutTitle": "About",
"aboutText": "Les Caramboles is a school dedicated to helping every child thrive in a caring and stimulating environment.",
"contactTitle": "Contact",
"contactText": "Get in touch to learn more about our school and programs."
}
}
What is the structure of these files?
Each JSON file uses a namespace (HomePage) that groups related translations. When you call useTranslations('HomePage') in your component, next-intl looks up keys inside that namespace.
Both files have identical keys but different values. This ensures every translation call resolves in both languages.
- Save messages/en.json.
- Now build the language switcher. Create components/language-switcher.tsx and paste this code:
'use client';
import {useLocale} from 'next-intl';
import {useRouter, usePathname} from 'next/navigation';
import {Button} from '@/components/ui/button';
// Toggle between French and English
export default function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
function switchLocale() {
const newLocale = locale === 'fr' ? 'en' : 'fr';
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
router.push(newPath);
}
return (
<Button variant="outline" onClick={switchLocale}>
{locale === 'fr' ? 'EN' : 'FR'}
</Button>
);
}
What does this code do?
The useLocale() hook reads the current locale from the URL. When the user clicks the button, it swaps /fr for /en (or vice versa) and navigates to the new path.
The 'use client' directive is required because this component uses browser events (onClick) and React hooks that need the browser.
- Save components/language-switcher.tsx.
- Now update app/[locale]/page.tsx to use translations and include the language switcher. Replace the file contents with:
import {useTranslations} from 'next-intl';
import {setRequestLocale} from 'next-intl/server';
import {use} from 'react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import LanguageSwitcher from '@/components/language-switcher';
type Props = {
params: Promise<{locale: string}>;
};
- Add the page component below the type definition in the same file:
export default function HomePage({params}: Props) {
const {locale} = use(params);
setRequestLocale(locale);
const t = useTranslations('HomePage');
return (
<main className="min-h-screen p-4 md:p-8 lg:p-12">
{/* Language Switcher */}
<div className="flex justify-end mb-4">
<LanguageSwitcher />
</div>
{/* Hero Section */}
<section className="text-center py-12 md:py-20">
<h1 className="text-3xl md:text-5xl font-bold mb-4">
{t('heroTitle')}
</h1>
<p className="text-lg md:text-xl mb-8">
{t('heroTagline')}
</p>
<Button asChild>
<a
href="https://form.jotform.com/230986513095563"
target="_blank"
rel="noopener noreferrer"
>
{t('ctaLabel')}
</a>
</Button>
</section>
- Add the About and Contact sections below the Hero section to complete the page:
{/* About Section */}
<section className="py-8 md:py-12">
<Card className="p-6">
<h2 className="text-2xl font-bold mb-4">
{t('aboutTitle')}
</h2>
<p>{t('aboutText')}</p>
</Card>
</section>
{/* Contact Section */}
<section className="py-8 md:py-12">
<Card className="p-6">
<h2 className="text-2xl font-bold mb-4">
{t('contactTitle')}
</h2>
<p>{t('contactText')}</p>
</Card>
</section>
</main>
);
}
What changed in the page?
Instead of hardcoded text, every string now comes from t('keyName'). The useTranslations('HomePage') hook loads the HomePage namespace from whichever JSON file matches the current locale.
The setRequestLocale(locale) call at the top enables static rendering. Without it, the page would fall back to dynamic rendering on every request.
- Save app/[locale]/page.tsx.
Before you check the browser, what do you think will happen when you navigate to localhost:3000? Will it show French, English, or redirect somewhere?
- Open your browser and navigate to localhost:3000.
The proxy redirects you to localhost:3000/fr and you see the page in French. The URL bar confirms the redirect.
- Click the EN button in the top right corner.
The URL changes to localhost:3000/en and all text switches to English. The button label changes to FR.
Page not loading or showing a blank screen?
Stop the dev server with Ctrl+C and restart it with npm run dev. Config changes to next.config.ts and proxy.ts require a server restart.
If you see a 404 at /fr, make sure app/[locale]/page.tsx exists. The square brackets in the folder name are literal. They must be part of the folder name.
Help me debug my next-intl locale routing.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these files is present and saved:
- src/i18n/routing.ts with defineRouting using ['fr', 'en'] locales.
- proxy.ts with createMiddleware and the matcher pattern.
- src/i18n/request.ts with getRequestConfig loading messages.
- next.config.ts with createNextIntlPlugin wrapping the config.
- app/[locale]/layout.tsx with generateStaticParams and setRequestLocale.
- app/layout.tsx as a minimal pass-through.
- messages/fr.json and messages/en.json with matching keys.
- components/language-switcher.tsx with the locale toggle.
- app/[locale]/page.tsx using useTranslations('HomePage') for all text.
Your school page now speaks two languages and the URL tells both the browser and search engines which version to serve. Next up, you will add a page loader, smooth animations, a Back to top button, and social media icons to bring the page to life.
Add Dark/Light Theme, Loader, and Animations
Add a Loader, Animations, and Back to Top
Your bilingual page works in French and English, but it feels flat. There is no loading feedback during navigation, no smooth scroll-to-top for long pages, and no social links connecting visitors to the school's Instagram, Facebook, or TikTok.
In this step, you will add a loader that plays during page transitions, subtle fade-in animations that bring sections to life, a Back to top button that appears on scroll, and social media icons in the footer.
In this step, get ready to:
- Build a loader component that animates during route changes.
- Add fade-in-up animations to page sections with Motion.
- Create a Back to top button and a social media icons footer.
Create the loader component
When users navigate between the French and English versions, there is a brief moment where nothing appears to happen. A loading animation gives visual feedback that the page is switching.
- Create a new file at components/loader.tsx and paste this code:
"use client"
import { useEffect, useState } from "react"
import { usePathname } from "next/navigation"
// Shows a loading bar during route transitions
export function Loader() {
const pathname = usePathname()
const [loading, setLoading] = useState(false)
useEffect(() => {
// Start loading animation
setLoading(true)
// Hide after a short delay to simulate transition
const timeout = setTimeout(() => {
setLoading(false)
}, 500)
return () => clearTimeout(timeout)
}, [pathname])
if (!loading) return null
return (
<div className="fixed top-0 left-0 right-0 z-50">
<div className="h-1 bg-primary animate-pulse" />
</div>
)
}
What does this code do?
- usePathname() from next/navigation returns the current URL path. Every time the path changes (e.g. /fr to /en), the effect re-runs.
- setLoading(true) shows a pulsing bar at the top of the viewport.
- After 500ms, the bar disappears. This gives enough visual feedback without blocking content.
- Save components/loader.tsx.
- Import and add the Loader to your page. Open app/[locale]/page.tsx and add this import:
import { Loader } from "@/components/loader"
- Place <Loader /> at the top of your page's JSX, before the Hero section.
- Save the file and click your language switcher to toggle between /fr and /en.
You should see a thin colored bar pulse at the very top of the page for half a second each time you switch locale.
Not seeing the loading bar?
The bar uses bg-primary which maps to your theme's primary color. If your background and primary color are too similar, you might not notice it. Try adding bg-blue-500 temporarily to confirm the bar renders, then switch back to bg-primary.
Help me debug my loader
Add fade-in animations with Motion
The page content currently appears instantly. Wrapping sections in Motion animation divs adds a subtle fade-in-up effect that makes the page feel more polished.
- In app/[locale]/page.tsx, add this import at the top of the file:
import { motion } from "motion/react"
Why motion/react and not framer-motion?
The library formerly known as Framer Motion was renamed to Motion. The correct import path is motion/react. The motion package is already in your package.json from Step 1.
- Wrap your Hero section in a motion.div like this:
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Your Hero section content */}
</motion.div>
What do these animation props mean?
- initial sets the starting state: invisible and shifted 20px down.
- animate sets the final state: fully visible and at its normal position.
- transition controls timing. Half a second is fast enough to feel snappy without lagging.
- Wrap your About section in the same motion.div pattern with identical props.
- Wrap your Contact section in the same motion.div pattern with identical props.
Each section gets its own motion.div wrapper so they all animate independently when the page loads.
- Save app/[locale]/page.tsx and refresh your browser.
Before you look, predict: will all three sections animate at the exact same time, or staggered?
All three sections fade in simultaneously since they share the same duration: 0.5 and no delay offset. You should see the Hero, About, and Contact sections smoothly appear with a gentle upward slide.
Seeing a hydration error about motion.div?
Motion components need to render on the client. If your page is a Server Component, you will need to add "use client" at the top of app/[locale]/page.tsx since it now uses useTranslations and motion (both client APIs).
Help me fix my hydration error
Add a Back to top button and social media icons
Long pages need two things: a way to get back to the top quickly, and social links so visitors can find the school on Instagram, Facebook, and TikTok. You will build both as small client components.
- Create a new file at components/back-to-top.tsx and paste this code:
"use client"
import { useEffect, useState } from "react"
import { ArrowUp } from "lucide-react"
import { Button } from "@/components/ui/button"
// Floating button that appears after scrolling 300px
export function BackToTop() {
const [visible, setVisible] = useState(false)
useEffect(() => {
const onScroll = () => {
setVisible(window.scrollY > 300)
}
window.addEventListener("scroll", onScroll)
return () => window.removeEventListener("scroll", onScroll)
}, [])
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" })
}
if (!visible) return null
return (
<Button
onClick={scrollToTop}
variant="outline"
size="icon"
className="fixed bottom-6 right-6 z-50 rounded-full shadow-lg"
aria-label="Back to top"
>
<ArrowUp className="h-5 w-5" />
</Button>
)
}
What does this code do?
- A scroll event listener tracks whether the user has scrolled past 300px. When they have, the button fades in.
- scrollToTop() uses behavior: "smooth" for a polished scroll animation instead of an instant jump.
- The button uses shadcn/ui's Button with the ArrowUp icon from Lucide, which is already bundled with shadcn/ui.
- Save components/back-to-top.tsx.
- Now create the social media icons. Create a new file at components/social-icons.tsx and paste this code:
import Link from "next/link"
import { Instagram } from "lucide-react"
// Social media links for the school's online presence
export function SocialIcons() {
return (
<div className="flex items-center gap-4">
<Link
href="https://www.instagram.com/les_caramboles"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
>
<Instagram className="h-5 w-5 hover:text-primary" />
</Link>
<Link
href="https://www.facebook.com/share/1EbhHGvgjW/"
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
>
<svg className="h-5 w-5 hover:text-primary" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
</Link>
<Link
href="https://www.tiktok.com/@lescaramboles"
target="_blank"
rel="noopener noreferrer"
aria-label="TikTok"
>
<svg className="h-5 w-5 hover:text-primary" fill="currentColor" viewBox="0 0 24 24">
<path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z" />
</svg>
</Link>
</div>
)
}
What does this code do?
- The Instagram icon comes from Lucide (bundled with shadcn/ui). Facebook and TikTok use inline SVGs because Lucide does not include brand icons.
- Each link opens in a new tab with target="_blank" and includes aria-label for screen readers since there is no visible text next to the icon.
- The hover:text-primary class changes the icon color on hover to match your shadcn/ui theme.
- Save components/social-icons.tsx.
- Open app/[locale]/page.tsx and add these two imports near the top of the file:
import { BackToTop } from "@/components/back-to-top"
import { SocialIcons } from "@/components/social-icons"
- Place <SocialIcons /> in your page's footer area, after the Contact section.
- Place <BackToTop /> at the very bottom of your page's JSX, just before the closing </main> tag.
- Save the file and refresh your browser.
Scroll down past the Hero section. The Back to top button should appear as a circular icon in the bottom-right corner. The Instagram, Facebook, and TikTok icons should be visible in the footer area.
- Click the Back to top button.
The page smoothly scrolls back to the top. Scroll down again and the button reappears.
Back to top button not showing?
The button only appears after scrolling 300px. If your page is too short, add enough content to make it scrollable, or temporarily change 300 to 100 in the scroll handler to test.
Help me debug my Back to top button
Final verification: loader, animations, back to top, and social icons
All four features should now work together. Run through this quick check.
- Refresh localhost:3000/fr and watch all three sections fade in from below.
- Click the language switcher to navigate to /en. You should see the loader bar pulse at the top during the transition.
- Scroll down past the Hero section. The Back to top button should appear in the bottom-right corner.
- Click the Back to top button. The page should smoothly scroll to the top.
- Confirm the Instagram, Facebook, and TikTok icons are visible and each opens the correct URL in a new tab.
✔️ All three features work
Excellent. Your page now has a route transition loader, smooth fade-in animations, a Back to top button, and social media icons linking to Instagram, Facebook, and TikTok.
ⓧ Something isn't working
Double-check these common issues:
- If animations don't play: confirm "use client" is at the top of your page file.
- If the loader never shows: confirm <Loader /> is rendered inside the page component (not inside a motion.div that might be hidden).
- If the Back to top button doesn't appear: scroll down past 300px and confirm <BackToTop /> is rendered at the bottom of your page JSX.
Help me troubleshoot my loader, animations, or Back to top button
Your page now looks and feels professional. The loader communicates that something is happening during navigation, the animations add polish, the Back to top button helps users navigate long pages, and the social icons connect visitors to the school's online presence. Next up, you will connect MongoDB so the page content can be managed dynamically.
Connect MongoDB and Make Content Dynamic
Right now, every word on your landing page lives inside static JSON translation files. If the school director wants to update a tagline, someone has to edit code and redeploy.
Connecting MongoDB turns your page into a dynamic site. Content lives in a database, and the page pulls it fresh on each request. This is the foundation for the future backoffice where the director will edit content without touching code.
In this step, get ready to:
- Create a cached Mongoose connection and a SchoolContent schema.
- Seed the database with French and English content, then update the page to fetch from MongoDB.
- Add environment variables and verify live content updates via mongosh.
Create the database connection and schema
In Next.js, every incoming request can spin up a new module instance. Without a cached connection, your app would open a new database connection on every page load. The singleton pattern stores one connection on a global variable so it survives hot reloads in development.
- Create a new file called lib/mongodb.ts inside your les-caramboles project folder.
- Add the cached connection function by pasting this code:
import mongoose from 'mongoose';
// Read the connection string from environment variables
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/les-caramboles';
// Cache connection on global to survive hot reloads in dev
let cached = (global as any).mongoose;
if (!cached) {
cached = (global as any).mongoose = { conn: null, promise: null };
}
// Reuse the existing connection or create a new one
export async function connectDB() {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
cached.promise = mongoose.connect(MONGODB_URI, {
bufferCommands: false,
});
}
cached.conn = await cached.promise;
return cached.conn;
}
What does this code do?
The global cache pattern stores the Mongoose connection promise on global.mongoose. On the first request, connectDB() calls mongoose.connect() and saves the promise. Every subsequent call returns the cached connection instantly.
The fallback URI (mongodb://localhost:27017/les-caramboles) lets the code work before you create .env.local later in this step.
- Now add the SchoolContent schema below the connection function in the same file:
// Define the shape of school content documents
const schoolContentSchema = new mongoose.Schema({
locale: String,
heroTitle: String,
heroTagline: String,
aboutText: String,
contactInfo: String,
});
// Reuse model if already compiled (prevents OverwriteModelError)
export const SchoolContent =
mongoose.models.SchoolContent ||
mongoose.model('SchoolContent', schoolContentSchema);
What does this code do?
The schema defines five fields that map to the content on your page: locale, heroTitle, heroTagline, aboutText, and contactInfo.
The mongoose.models.SchoolContent || mongoose.model(...) pattern checks whether the model was already registered. Without this guard, Next.js hot reloads would throw an OverwriteModelError.
- Save lib/mongodb.ts.
Seeing a TypeScript error?
If VS Code underlines the global as any cast, make sure your file is saved with the .ts extension and that mongoose is listed in your package.json dependencies.
Help me fix a TypeScript error in lib/mongodb.ts
Seed the database and update the page
With the connection and schema ready, you need data in the database for the page to display. A seed script inserts the French and English content documents in one command.
- Create a new folder called scripts in the root of your project.
- Create a file called seed.ts inside scripts/ and paste the schema setup:
import mongoose from 'mongoose';
// Connect directly for seeding (no env dependency)
const MONGODB_URI = 'mongodb://localhost:27017/les-caramboles';
const schoolContentSchema = new mongoose.Schema({
locale: String,
heroTitle: String,
heroTagline: String,
aboutText: String,
contactInfo: String,
});
const SchoolContent = mongoose.model('SchoolContent', schoolContentSchema);
- Below the schema, add the seed function that inserts both language documents:
async function seed() {
await mongoose.connect(MONGODB_URI);
// Clear any existing documents
await SchoolContent.deleteMany({});
await SchoolContent.create({
locale: 'fr',
heroTitle: 'Les Caramboles',
heroTagline: "Une école où chaque enfant s'épanouit",
aboutText: "Les Caramboles est une école dédiée à l'épanouissement de chaque enfant.",
contactInfo: 'contact@lescaramboles.com | +596 696 00 00 00',
});
await SchoolContent.create({
locale: 'en',
heroTitle: 'Les Caramboles',
heroTagline: 'A school where every child thrives',
aboutText: 'Les Caramboles is a school dedicated to helping every child thrive.',
contactInfo: 'contact@lescaramboles.com | +596 696 00 00 00',
});
console.log('Database seeded successfully!');
await mongoose.disconnect();
}
seed();
What does this code do?
The seed() function connects directly to MongoDB using a hardcoded URI (safe for a local script), wipes any existing content, then inserts one French document and one English document.
Once it finishes, it prints a confirmation and disconnects cleanly.
- Save scripts/seed.ts.
- Run the seed script by executing this command in your terminal:
npx tsx scripts/seed.ts
- You should see Database seeded successfully! printed in your terminal.
Seeing a connection error?
Make sure mongod is still running in a separate terminal window. If it stopped, restart it with:
mongod --dbpath /usr/local/var/mongodb --logpath /usr/local/var/log/mongodb/mongo.log --fork
Help me fix a MongoDB connection error when running my seed script
Now you need to convert your page from a client component (using useTranslations) to a server component that fetches from the database. Since motion.div requires client-side rendering, you will create a small wrapper component first.
- Create a new file components/animated-section.tsx and paste:
'use client';
import { motion } from 'motion/react';
// Client wrapper for motion animations in server component pages
export function AnimatedSection({
children,
delay = 0,
}: {
children: React.ReactNode;
delay?: number;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay }}
>
{children}
</motion.div>
);
}
Why a separate client component?
Server components cannot use browser APIs or React hooks. The Motion library uses hooks internally, so any component rendering motion.div must be a client component.
By extracting just the animation wrapper into its own file with 'use client', the page stays a server component and can fetch data directly from the database.
- Save components/animated-section.tsx.
- Now open app/[locale]/page.tsx and replace the entire file content with this server component that fetches from MongoDB:
import { connectDB, SchoolContent } from '@/lib/mongodb';
import { setRequestLocale } from 'next-intl/server';
import { AnimatedSection } from '@/components/animated-section';
import LanguageSwitcher from '@/components/language-switcher';
import { BackToTop } from '@/components/back-to-top';
import { Loader } from '@/components/loader';
import { Button } from '@/components/ui/button';
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
// Fetch school content from MongoDB for the current locale
await connectDB();
const content = await SchoolContent.findOne({ locale }).lean();
- Below the data fetching, add the JSX that renders the dynamic content:
return (
<main className="min-h-screen p-8">
<Loader />
<nav className="flex justify-end gap-2 mb-8">
<LanguageSwitcher />
</nav>
<AnimatedSection>
<section className="text-center py-16">
<h1 className="text-4xl font-bold">{content?.heroTitle}</h1>
<p className="text-xl mt-4">{content?.heroTagline}</p>
<Button asChild className="mt-8">
<a
href="https://form.jotform.com/230986513095563"
target="_blank"
rel="noopener noreferrer"
>
{locale === 'fr' ? "S'inscrire" : 'Enroll Now'}
</a>
</Button>
</section>
</AnimatedSection>
<AnimatedSection delay={0.2}>
<section className="max-w-2xl mx-auto py-8">
<p>{content?.aboutText}</p>
</section>
</AnimatedSection>
<AnimatedSection delay={0.4}>
<section className="text-center py-8">
<p>{content?.contactInfo}</p>
</section>
</AnimatedSection>
<BackToTop />
</main>
);
}
What changed in the page?
- The page is now an async server component. It can call await connectDB() directly.
- Instead of useTranslations() (a client hook), it reads content from MongoDB using the locale from the URL.
- The AnimatedSection client component wraps each section to preserve the fade-in animations.
- Save app/[locale]/page.tsx.
- Return to your browser and refresh localhost:3000/fr.
- You should see the hero title "Les Caramboles", the French tagline, and the contact info rendered from the database.
Seeing a blank page or error?
Stop and restart your dev server with npm run dev. Next.js needs to reload after you add the database connection.
If you see a "Cannot find module" error, check that lib/mongodb.ts is in the lib folder at the project root (not inside app/).
Help me fix my page after converting it to a server component with MongoDB
Add environment variables and verify live updates
The fallback URI in your connection file works, but production apps should never hardcode database credentials. Next.js loads .env.local automatically for local development.
- Create a file called .env.local in the root of your les-caramboles project and add:
MONGODB_URI=mongodb://localhost:27017/les-caramboles
- Save .env.local.
- Open your .gitignore file and confirm that .env.local is listed. Next.js scaffolds include this by default, but verify it is present.
Why add .env.local to .gitignore?
Environment files often contain secrets (API keys, database credentials for production). Keeping .env.local out of version control protects those values from being shared accidentally.
- Restart your dev server by pressing Ctrl+C and running:
npm run dev
Before you test, what do you think will happen if you change a value directly in the database and refresh?
- Open a new terminal tab and launch the MongoDB shell by running:
mongosh
- In the mongosh prompt, switch to your database and update the French tagline:
use les-caramboles
db.schoolcontents.updateOne(
{ locale: 'fr' },
{ $set: { heroTagline: 'Bienvenue chez Les Caramboles!' } }
)
- You should see { modifiedCount: 1 } in the mongosh output.
- Refresh localhost:3000/fr in your browser.
- The tagline now reads "Bienvenue chez Les Caramboles!" instead of the original text.
The page is now fully dynamic. Content changes in the database appear on the next page load without any code change or redeploy.
- Reset the tagline back to the original value for consistency by running in mongosh:
db.schoolcontents.updateOne(
{ locale: 'fr' },
{ $set: { heroTagline: "Une école où chaque enfant s'épanouit" } }
)
- Type exit to close mongosh.
Page still shows old content after updating?
Make sure you are refreshing the correct URL (localhost:3000/fr, not localhost:3000). The root URL redirects to the default locale, which may cache the redirect.
Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
Help me debug why my page is not showing updated MongoDB content
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved: lib/mongodb.ts, scripts/seed.ts, and app/[locale]/page.tsx.
ⓧ I'd like to double check the full code
Scroll back through the step and confirm each file matches what you pasted. The key files are:
- lib/mongodb.ts with the connectDB function and SchoolContent schema.
- scripts/seed.ts with the schema definition and the seed() function.
- components/animated-section.tsx with the client-side motion wrapper.
- app/[locale]/page.tsx as an async server component importing connectDB and SchoolContent.
- .env.local with MONGODB_URI=mongodb://localhost:27017/les-caramboles.
Your page now serves dynamic content from MongoDB. Next up, you will make this page discoverable by search engines with full SEO metadata, Open Graph tags, and security headers.
Add SEO and Security Headers
Your page now pulls dynamic content from MongoDB and looks polished with animations and theming. But there is one problem: search engines cannot find it.
Without proper metadata, Les Caramboles will not appear in Google results. Social shares on Facebook and TikTok will show a blank preview instead of a rich card. In this step, you will add SEO metadata, structured data for search engines, and security headers to protect against common web attacks.
In this step, get ready to:
- Export a locale-aware generateMetadata function with Open Graph and Twitter card metadata.
- Add JSON-LD structured data describing the school for search engines.
- Configure security headers in next.config.ts to protect against clickjacking, MIME-sniffing, and XSS.
Add the generateMetadata function for SEO
Next.js provides a Metadata API that lets you export a generateMetadata function from any page. This function returns an object that Next.js renders as <title>, <meta>, and Open Graph tags in the page <head>.
- In VS Code, open app/[locale]/page.tsx.
- Add this import at the top of the file, alongside your existing imports:
import type { Metadata } from 'next'
- Add the following generateMetadata function above your existing default export function:
// Generate locale-aware SEO metadata for search engines and social sharing
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const title = locale === 'fr'
? 'Les Caramboles - École maternelle'
: 'Les Caramboles - Preschool'
const description = locale === 'fr'
? 'École maternelle Les Caramboles - Inscriptions ouvertes'
: 'Les Caramboles Preschool - Enrollment open'
return {
title,
description,
openGraph: {
title,
description,
url: 'https://lescaramboles.com',
siteName: 'Les Caramboles',
type: 'website',
locale: locale === 'fr' ? 'fr_FR' : 'en_US',
images: ['/og-image.png'],
},
twitter: {
card: 'summary_large_image',
title,
description,
},
}
}
What does this code do?
The generateMetadata function reads the current locale from the URL params (which is a Promise in Next.js 16, so you must await it). It then returns locale-specific title and description values.
The openGraph object controls how the page looks when shared on Facebook. The twitter object sets the card type for Twitter/X shares. Next.js automatically renders these as the correct <meta> tags in the page <head>.
- Save app/[locale]/page.tsx.
- In your browser, navigate to localhost:3000/fr.
- Right-click the page and select View Page Source.
- You should see a <title> tag with Les Caramboles - École maternelle and <meta property="og:title"> tags in the HTML <head>.
Don't see the meta tags in the source?
Make sure you are using View Page Source (which shows the server-rendered HTML), not Inspect Element (which shows the live DOM after client-side hydration). Also confirm the function is exported (not just defined) and placed above the default export.
help me debug missing meta tags in my Next.js page source
Add JSON-LD structured data
Search engines use JSON-LD structured data to understand what a page represents. A SchoolOrganization schema tells Google this is a school, with a name, address, and social profiles.
- Create a new file components/json-ld.tsx and add the following code:
// Structured data for search engine rich results
export default function JsonLd() {
const schema = {
'@context': 'https://schema.org',
'@type': 'School',
name: 'Les Caramboles',
url: 'https://lescaramboles.com',
address: {
'@type': 'PostalAddress',
addressCountry: 'FR',
},
sameAs: [
'https://www.facebook.com/share/1EbhHGvgjW/?mibextid=wwXIfr',
'https://www.tiktok.com/@lescaramboles?_r=1&_t=ZS-98OLvDUFFx2',
],
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
)
}
What does this code do?
The JsonLd component renders a hidden <script type="application/ld+json"> tag containing a schema.org School object. Search engines parse this to display rich results (school name, location, social links).
The sameAs array tells Google that these Facebook and TikTok pages belong to the same organization.
- Save components/json-ld.tsx.
- Switch back to app/[locale]/page.tsx.
- Add this import at the top of the file:
import JsonLd from '@/components/json-ld'
- Inside your page component's return statement, add <JsonLd /> at the top, before your other JSX content.
- Save app/[locale]/page.tsx.
- Refresh localhost:3000/fr and view the page source again.
- Search for application/ld+json in the source. You should see the JSON-LD script with Les Caramboles and the Facebook/TikTok URLs.
Don't see the JSON-LD script in the source?
Make sure you imported JsonLd and placed <JsonLd /> inside the return statement of your page component. The component must render inside the page JSX, not outside it.
help me find why my JSON-LD component is not rendering
Configure security headers
Security headers instruct browsers to restrict dangerous behaviors like clickjacking, MIME-type sniffing, and cross-site scripting. Next.js lets you define these globally in next.config.ts using the headers() config function.
- Open next.config.ts in VS Code.
- Replace the entire file contents with the following code:
import createNextIntlPlugin from 'next-intl/plugin'
import type { NextConfig } from 'next'
const withNextIntl = createNextIntlPlugin()
// Content Security Policy for the school site
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
frame-ancestors 'none';
upgrade-insecure-requests;
`
What is this CSP string?
A Content-Security-Policy header tells the browser exactly which sources of scripts, styles, and images are allowed. This starter policy allows scripts and styles from your own domain ('self') plus inline scripts that Next.js needs to hydrate.
The frame-ancestors 'none' directive prevents other sites from embedding your page in an iframe (clickjacking protection).
- Below the cspHeader string, add the Next.js config with the headers() function:
const nextConfig: NextConfig = {
// Security headers applied to all routes
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{
key: 'Content-Security-Policy',
value: cspHeader.replace(/\n/g, '').trim(),
},
],
},
]
},
}
export default withNextIntl(nextConfig)
What do these headers do?
- X-Content-Type-Options: nosniff prevents browsers from guessing the MIME type of a response, blocking a class of XSS attacks.
- X-Frame-Options: DENY blocks any site from embedding your page in an iframe (prevents clickjacking).
- Referrer-Policy controls how much URL information is sent when navigating away from your site.
- Permissions-Policy disables browser APIs (camera, microphone, geolocation) that the school site does not use.
- Content-Security-Policy restricts which sources the browser will load scripts, styles, and other resources from.
- Save next.config.ts.
- The dev server will restart automatically.
Before you check the headers, do you think you will see them in the page source or somewhere else?
- Open localhost:3000/fr in your browser.
- Open DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Network tab.
- Refresh the page.
- Click the first request (the document request for /fr).
- In the Response Headers section, you should see x-content-type-options: nosniff and x-frame-options: DENY.
Security headers appear in the HTTP response, not in the HTML source. That is why you check the Network tab instead of View Page Source.
Don't see the security headers?
Make sure you saved next.config.ts and waited for the dev server to restart. Check the terminal for any config errors. Also confirm you are looking at the document request (not a static asset like a .js file).
help me debug missing security headers in my Next.js dev server
✔️ Awesome, I've got everything!
Great. Double check that you have saved both app/[locale]/page.tsx and next.config.ts.
ⓧ I'd like to double check the full code
Scroll up and confirm you have the following in place:
- The import type { Metadata } from 'next' and import JsonLd from '@/components/json-ld' imports at the top of app/[locale]/page.tsx.
- The generateMetadata function above your default export.
- A <JsonLd /> component rendered at the top of your page return.
- The full components/json-ld.tsx file with the School schema.
- The next.config.ts file with the cspHeader string and the headers() function returning all five security headers.
Your school page is now findable by search engines, shareable on social media with rich previews, and protected by industry-standard security headers. Next up, you will take on the Secret Mission and build a responsive mobile navigation with social links.
Secret mission
Mobile Navigation with Social Links
The desktop layout works, but on a phone there is no way to navigate. Build a responsive hamburger menu with an animated slide-in drawer, accessible keyboard handling, and social media links for Facebook and TikTok.
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 school content (data stored in /usr/local/var/mongodb).
- MongoDB log files (stored in /usr/local/var/log/mongodb).
- MongoDB binaries installed at /usr/local/bin/.
- Next.js project folder les-caramboles on your Desktop.
- Next.js development server (running in your terminal).
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 running in its terminal window. The next project in this series uses the same MongoDB instance.
- Leave the Next.js dev server running if you want to keep viewing the site.
- The MongoDB binaries at /usr/local/bin/ remain installed and ready for future projects.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files and database data stay on disk.
- Stop the Next.js dev server by pressing Ctrl+C in its terminal.
- Stop MongoDB by pressing Ctrl+C in the terminal where mongod is running.
- Your data persists in /usr/local/var/mongodb. When you are ready to resume, start mongod again with the same --dbpath flag and run npm run dev in your project folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Stop the Next.js dev server by pressing Ctrl+C in its terminal.
- Stop MongoDB by pressing Ctrl+C in the terminal where mongod is running. Alternatively, run the following command to kill the process:
pkill -f mongod
- Remove the MongoDB data directory by running:
rm -rf /usr/local/var/mongodb
- Remove the MongoDB log directory by running:
rm -rf /usr/local/var/log/mongodb
- Remove the MongoDB binaries from your system by running:
sudo rm /usr/local/bin/mongod*
sudo rm /usr/local/bin/mongos*
- Remove the project folder by running:
rm -rf ~/Desktop/les-caramboles
- Verify MongoDB is fully removed by running:
ls /usr/local/bin/mongo*
You should see a "No such file or directory" or "no matches found" message confirming the binaries are gone.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade, bilingual school landing page from scratch using Next.js 16, MongoDB, and shadcn/ui.
You've learned how to:
- Build a bilingual Next.js 16 application with locale-based URL routing (/fr and /en), smooth page animations using Motion, and a Back to top button with social media links for Instagram, Facebook, and TikTok.
- Install MongoDB Community Edition locally without Homebrew and connect it to a Next.js server component so content updates never require a code change.
- Configure production-ready SEO (Open Graph for Facebook/TikTok, JSON-LD structured data) and security headers (CSP, X-Frame-Options, HSTS) to make the page discoverable by search engines and protected against common attacks.
- Secret Mission: Built a responsive mobile hamburger navigation with social media links and accessible drawer animations.
Ready to quiz yourself?