Skip to content
Learni
View all tutorials
Développement Fullstack

How to Implement a Training Plans System with Next.js in 2026

Lire en français

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

terminal
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 prisma

This 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

prisma/schema.prisma
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

terminal
npx prisma generate
npx prisma db push
npx prisma studio

Generate 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)

app/api/plans/route.ts
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

app/api/plans/route.ts
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

src/app/plans/page.tsx
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

src/app/layout.tsx
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 on order and planId.
  • Secure the API: Add NextAuth v5 and roles (admin for POST).
  • Advanced caching: revalidateTag on 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.