Introduction
In 2026, online training platforms demand dynamic systems to manage modular training plans, complete with progress tracking and personalization. This expert tutorial guides you through building a full-featured manager using Next.js 15+ (App Router), Prisma for the ORM, Zod for validation, and React Query for caching.
Why does it matter? Training plans are no longer static: they feature hierarchical modules (plan → module → lesson), metadata (duration, level), and a scalable REST API. You'll model a relational database, secure endpoints with basic authentication, and build a reactive UI.
By the end, you'll have a working project ready to deploy on Vercel, handling 1000+ plans with zero latency. Ideal for lead devs building LMS (Learning Management Systems). Estimated time: 2 hours for experts.
Prerequisites
- Node.js 20+ and npm/yarn/pnpm
- Advanced knowledge of TypeScript, Next.js App Router, and Prisma
- Tools: VS Code with Prisma and TypeScript extensions
- Git for version control
- SQLite (for dev) or PostgreSQL (prod)
Initialize the Next.js Project
npx create-next-app@latest plan-formation-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd plan-formation-app
npm install prisma @prisma/client zod @tanstack/react-query
npm install -D prismaThis command sets up a Next.js 15+ project optimized for TypeScript and Tailwind. We add Prisma for the ORM, Zod for API schema validation, and React Query for async state management. Skip legacy templates: App Router is essential for 2026 performance.
Database Setup
Before modeling, set up Prisma. Create a .env file with DATABASE_URL="file:./dev.db" for SQLite in development. Relations will be TrainingPlan 1:N Module, Module 1:N Lesson. Use enums for level (BEGINNER, EXPERT) and status (IN_PROGRESS, COMPLETED).
Complete Prisma Schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model PlanFormation {
id String @id @default(cuid())
titre String
description String?
niveau Niveau @default(DEBUTANT)
duree Int // en heures
modules Module[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Module {
id String @id @default(cuid())
titre String
ordre Int
planId String
plan PlanFormation @relation(fields: [planId], references: [id], onDelete: Cascade)
lecons Lecon[]
createdAt DateTime @default(now())
}
model Lecon {
id String @id @default(cuid())
titre String
contenu String // Markdown ou vidéo URL
duree Int
moduleId String
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
ordre Int
}
enum Niveau {
DEBUTANT
INTERMEDIAIRE
EXPERT
}This schema defines a full hierarchy: plans contain modules, modules contain lessons. Use cuid() for distributed IDs and Cascade for clean deletions. Pro tip: Don't skip @updatedAt for audits; switch to PostgreSQL in production for scaling.
Generate and Migrate Prisma
npx prisma generate
npx prisma db push
npx prisma studioGenerate the Prisma client, sync the DB with db push (faster than migrate for dev), and open Studio to inspect. In production, use prisma migrate dev. Avoid prisma db pull without backups: it overwrites schemas.
Implementing API Routes
Create REST routes in app/api. Use Zod for payload validation, Prisma for optimized queries (select/include to avoid N+1), and error handling with NextResponse. For experts: add caching with revalidatePath and secure headers.
API GET All Plans (with Modules)
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET() {
try {
const plans = await prisma.planFormation.findMany({
select: {
id: true,
titre: true,
description: true,
niveau: true,
duree: true,
modules: {
select: {
id: true,
titre: true,
ordre: true,
lecons: {
select: {
id: true,
titre: true,
duree: true,
},
},
},
orderBy: { ordre: 'asc' },
},
},
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(plans);
} catch (error) {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
}
}This route fetches all plans with nested modules and lessons via select for optimization (no N+1). orderBy ensures logical sorting. Expert pitfall: Without select, you load everything; test with 1000+ records for performance.
API POST Create a Plan
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const createSchema = z.object({
titre: z.string().min(3),
description: z.string().optional(),
niveau: z.enum(['DEBUTANT', 'INTERMEDIAIRE', 'EXPERT']).default('DEBUTANT'),
duree: z.number().min(1),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const data = createSchema.parse(body);
const plan = await prisma.planFormation.create({ data });
return NextResponse.json(plan, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors }, { status: 400 });
}
return NextResponse.json({ error: 'Erreur création' }, { status: 500 });
}
}Strict validation with Zod parse; atomic creation. Zod errors returned cleanly. Add auth middleware in production (NextAuth). Pitfall: Without try/catch on Prisma, transactions fail silently.
Plans List Component with React Query
import { useQuery } from '@tanstack/react-query';
const fetchPlans = async () => {
const res = await fetch('/api/plans');
if (!res.ok) throw new Error('Erreur fetch');
return res.json();
};
export default function PlansPage() {
const { data: plans, isLoading, error } = useQuery({
queryKey: ['plans'],
queryFn: fetchPlans,
});
if (isLoading) return <div>Chargement...</div>;
if (error) return <div>Erreur: {error.message}</div>;
return (
<div className="p-8 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Plans de Formation</h1>
<div className="grid gap-6 md:grid-cols-2">
{plans?.map((plan: any) => (
<div key={plan.id} className="border p-6 rounded-lg shadow">
<h2 className="text-2xl font-semibold">{plan.titre}</h2>
<p className="text-gray-600">{plan.description}</p>
<p>Niveau: {plan.niveau} | Durée: {plan.duree}h</p>
<details>
<summary>Modules ({plan.modules.length})</summary>
<ul className="mt-2 ml-4">
{plan.modules.map((m: any) => (
<li key={m.id}>{m.titre} ({m.lecons.length} leçons)</li>
))}
</ul>
</details>
</div>
))}
</div>
</div>
);
}React Query handles fetching, caching, and auto-refetching. Responsive Tailwind UI with nested details. Pitfall: Without a unique queryKey, cache gets corrupted; add staleTime: 5 60 1000 for production.
React Query Provider in Layout
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import './globals.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 2,
},
},
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="fr">
<body>
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</body>
</html>
);
}Global layout wraps with a configured QueryClient (5min staleTime, retry). DevTools for debugging. Essential for expert apps: centralizes async state without Redux boilerplate.
Best Practices
- Optimize Prisma queries: Always use explicit
select, indexes onorderandplanId. - Secure the API: Add NextAuth v5 and roles (admin for POST).
- Advanced caching:
revalidateTagon mutations, SWR for edge cases. - Testing: Jest + MSW for API mocks, Playwright for E2E.
- Deployment: Vercel with Prisma Accelerate for global DB.
Common Errors to Avoid
- Forgetting
prisma.$disconnect()in dev: causes memory leaks (use a global singleton). - N+1 queries: Without
include/select, performance drops to 10 req/module. - Lax validation: Zod prevents injections; without it, SQLi via Prisma raw.
- No transactions: For atomic plan+modules creation, wrap in
prisma.$transaction().
Next Steps
Extend with progress tracking (UserProgress model), AI recommendations (OpenAI API), or PWA for offline use.
Check out our Learni trainings on Next.js and Prisma to master advanced fullstack architectures. Resources: Prisma Relations Docs, React Query Guide.