Building a 25-Course Catalog Without Writing 25 Pages
How a centralized data layer plus dynamic [slug] routes in Next.js kept a growing course catalog maintainable, with per-page SEO handled automatically.
When I started building the course catalog for an SAP and Data Science training institute, the brief was 25 courses across four categories, plus 6 workshops, each needing its own page, its own metadata, and its own curriculum breakdown. The naive version of this is 31 separate page files, each slightly different from the last, each one a future maintenance problem.
The version that actually shipped is one page template, one data file, and a build step that turns the two into 31 static pages.
Start with the data, not the pages
Before writing any routing code, I modeled what a course actually is:
type Course = {
slug: string;
title: string;
category: "sap-functional" | "sap-technical" | "data-science";
summary: string;
duration: string;
curriculum: { module: string; topics: string[] }[];
seo: { title: string; description: string };
};
Every course, regardless of category, is an object of this shape, living in a single courses.ts file. This sounds obvious in hindsight, but the temptation when you're moving fast is to let the page component be the source of truth — hardcoding content directly into JSX per course. That works for three courses. It breaks down completely at fifteen.
One route, driven by the data
With the data modeled, the routing is almost mechanical:
src/app/courses/[slug]/page.tsx
export function generateStaticParams() {
return courses.map((course) => ({ slug: course.slug }));
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const course = courses.find((c) => c.slug === slug);
return {
title: course.seo.title,
description: course.seo.description,
};
}
generateStaticParams tells Next.js which pages to build at compile time, so every course page is fully static — no runtime database call, no loading state, no risk of a slow API request hurting Core Web Vitals on a page whose entire job is to rank in search and convert a visitor. generateMetadata means every one of those 31 pages gets a unique title and description without a single line of duplicated JSX.
Curriculum as structured data, not prose
Curriculum content is where a lot of course sites default to a wall of text. I structured it instead as an array of modules and topics, then rendered it through a single CurriculumAccordion component:
curriculum: [
{ module: "SAP MM Fundamentals", topics: ["Organizational structure", "Master data", "Procurement cycle"] },
{ module: "Inventory Management", topics: ["Goods movement", "Physical inventory", "Valuation"] },
]
This does two things at once. It makes the accordion trivial to build — it's just a map over an array, no per-course custom markup. And it makes the content genuinely scannable for someone comparing five courses in five tabs, which is closer to how people actually evaluate a training program than reading a paragraph twice.
Why this matters more than it looks like it does
The real payoff of this pattern isn't the code being shorter, though it is. It's that adding course number 26 next quarter means adding one object to one array — no new component, no new route file, no risk of copy-pasting a bug from course 14 into course 26. The data layer is the thing that scales; the template is just how it gets rendered. That distinction is worth designing for from the first course, not the fifteenth.