Construire une application full-stack avec Prisma ORM et Next.js 15 App Router

Noqta Team
Par Noqta Team ·

Chargement du lecteur de synthèse vocale...

Le ORM TypeScript le plus populaire rencontre la stack React moderne. Prisma vous offre un schéma déclaratif, des types auto-générés et un moteur de requêtes puissant — le tout intégré parfaitement avec Next.js 15 Server Components et Server Actions. Dans ce tutoriel, vous construirez une application complète de gestion de projets à partir de zéro.

Ce que vous apprendrez

À la fin de ce tutoriel, vous serez capable de :

  • Configurer Prisma ORM avec PostgreSQL dans un projet Next.js 15 App Router
  • Définir des modèles et des relations avec Prisma Schema Language
  • Exécuter des migrations et seeder la base de données
  • Construire des opérations CRUD complètes avec les Server Actions de Next.js
  • Utiliser le Prisma Client pour des requêtes typées, des filtres et la pagination
  • Gérer la validation des formulaires avec Zod et useActionState
  • Déployer en production avec les meilleures pratiques

Prérequis

Avant de commencer, assurez-vous de disposer de :

  • Node.js 20+ installé (node --version)
  • Expérience en TypeScript (types, interfaces, async/await)
  • Familiarité avec Next.js (App Router, Server Components)
  • PostgreSQL en local ou une instance cloud (nous utiliserons Neon)
  • Un éditeur de code — VS Code ou Cursor recommandé

Pourquoi Prisma ORM ?

Prisma est le ORM TypeScript le plus adopté, utilisé par des entreprises comme Netflix, Notion et Hashicorp. Voici ce qui le distingue :

FonctionnalitéPrismaDrizzleTypeORM
SchémaDSL déclaratif (.prisma)TypeScriptDécorateurs
TypageTypes auto-générésTypes inférésPartiel
MigrationsCLI intégrédrizzle-kitCLI ou manuel
RelationsPremière classe, écritures imbriquéesJointures manuellesDécorateurs
API de requêteAPI objet intuitiveAPI SQL-likePattern Repository
StudioGUI intégré (Prisma Studio)Drizzle StudioAucun
Support EdgePrisma AccelerateNatifNon optimisé

Prisma adopte une approche schema-first : vous déclarez votre modèle de données dans un fichier .prisma, et Prisma génère un client entièrement typé avec autocomplétion pour chaque champ, relation et filtre. Aucune définition de type manuelle nécessaire.


Ce que vous construirez

Une application de gestion de projets avec :

  • Des projets contenant plusieurs tâches
  • Des tâches avec statut, priorité et dates limites
  • CRUD complet pour les projets et les tâches
  • Filtrage et tri
  • Interface responsive avec Tailwind CSS

Étape 1 : Créer un projet Next.js 15

Créez un nouveau projet :

npx create-next-app@latest project-manager --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd project-manager

Acceptez les valeurs par défaut. Cela crée un projet Next.js 15 avec App Router, TypeScript et Tailwind CSS.


Étape 2 : Installer Prisma

Installez Prisma en dépendance de développement et le Prisma Client :

npm install prisma --save-dev
npm install @prisma/client

Initialisez Prisma avec PostgreSQL comme fournisseur :

npx prisma init --datasource-provider postgresql

Cela crée deux fichiers :

  • prisma/schema.prisma — la définition de votre modèle de données
  • .env — la chaîne de connexion à la base de données

Étape 3 : Configurer la base de données

Ouvrez .env et définissez votre chaîne de connexion PostgreSQL :

DATABASE_URL="postgresql://user:password@localhost:5432/project_manager?schema=public"

Si vous utilisez Neon (recommandé pour une configuration rapide) :

  1. Créez un compte gratuit sur neon.tech
  2. Créez un nouveau projet
  3. Copiez la chaîne de connexion depuis le tableau de bord
  4. Collez-la dans .env

Si vous exécutez PostgreSQL localement avec Docker :

docker run --name pg-prisma -e POSTGRES_USER=user -e POSTGRES_PASSWORD=password -e POSTGRES_DB=project_manager -p 5432:5432 -d postgres:16

Étape 4 : Définir votre schéma Prisma

Ouvrez prisma/schema.prisma et définissez le modèle de données :

generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model Project {
  id          String   @id @default(cuid())
  name        String
  description String?
  color       String   @default("#3b82f6")
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  tasks Task[]
 
  @@map("projects")
}
 
model Task {
  id          String     @id @default(cuid())
  title       String
  description String?
  status      TaskStatus @default(TODO)
  priority    Priority   @default(MEDIUM)
  dueDate     DateTime?
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
 
  projectId String
  project   Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
 
  @@index([projectId])
  @@index([status])
  @@map("tasks")
}
 
enum TaskStatus {
  TODO
  IN_PROGRESS
  DONE
}
 
enum Priority {
  LOW
  MEDIUM
  HIGH
  URGENT
}

Points clés de ce schéma :

  • @id @default(cuid()) génère automatiquement des identifiants uniques
  • @updatedAt met à jour automatiquement le timestamp à chaque modification
  • Task[] sur Project définit une relation un-à-plusieurs
  • onDelete: Cascade supprime toutes les tâches quand un projet est supprimé
  • @@index crée des index de base de données pour des requêtes plus rapides
  • @@map mappe le modèle à un nom de table spécifique
  • Enums fournissent des valeurs de statut et de priorité typées

Étape 5 : Exécuter votre première migration

Générez et appliquez la migration :

npx prisma migrate dev --name init

Cette commande fait trois choses :

  1. Crée un fichier de migration SQL dans prisma/migrations/
  2. Applique la migration à votre base de données
  3. Génère le Prisma Client avec des types TypeScript complets

Vous pouvez inspecter le SQL généré dans prisma/migrations/[timestamp]_init/migration.sql.


Étape 6 : Créer le Singleton Prisma Client

Dans un environnement Next.js, le rechargement à chaud peut créer plusieurs instances de Prisma Client. Créez un singleton pour éviter cela.

Créez src/lib/prisma.ts :

import { PrismaClient } from "@prisma/client";
 
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};
 
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
 
if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

Cela garantit qu'une seule instance de PrismaClient existe pendant le développement.


Étape 7 : Seeder la base de données

Créez prisma/seed.ts pour remplir la base de données avec des données exemples :

import { PrismaClient, TaskStatus, Priority } from "@prisma/client";
 
const prisma = new PrismaClient();
 
async function main() {
  // Nettoyer les données existantes
  await prisma.task.deleteMany();
  await prisma.project.deleteMany();
 
  // Créer des projets avec des tâches
  const webApp = await prisma.project.create({
    data: {
      name: "Refonte application web",
      description: "Refonte complète du site web de la société",
      color: "#3b82f6",
      tasks: {
        create: [
          {
            title: "Designer la nouvelle page d'accueil",
            description: "Créer les wireframes et maquettes pour la homepage",
            status: TaskStatus.DONE,
            priority: Priority.HIGH,
            dueDate: new Date("2026-04-15"),
          },
          {
            title: "Implémenter l'authentification",
            description: "Configurer login, inscription et réinitialisation du mot de passe",
            status: TaskStatus.IN_PROGRESS,
            priority: Priority.URGENT,
            dueDate: new Date("2026-04-20"),
          },
          {
            title: "Construire le tableau de bord",
            description: "Créer le dashboard utilisateur principal avec des graphiques",
            status: TaskStatus.TODO,
            priority: Priority.MEDIUM,
            dueDate: new Date("2026-05-01"),
          },
          {
            title: "Rédiger la documentation API",
            status: TaskStatus.TODO,
            priority: Priority.LOW,
          },
        ],
      },
    },
  });
 
  const mobileApp = await prisma.project.create({
    data: {
      name: "MVP Application Mobile",
      description: "Application React Native pour iOS et Android",
      color: "#10b981",
      tasks: {
        create: [
          {
            title: "Configurer le projet Expo",
            status: TaskStatus.DONE,
            priority: Priority.HIGH,
          },
          {
            title: "Construire la structure de navigation",
            status: TaskStatus.IN_PROGRESS,
            priority: Priority.HIGH,
            dueDate: new Date("2026-04-10"),
          },
          {
            title: "Intégrer les notifications push",
            status: TaskStatus.TODO,
            priority: Priority.MEDIUM,
            dueDate: new Date("2026-05-15"),
          },
        ],
      },
    },
  });
 
  console.log("Projets seedés :", { webApp: webApp.id, mobileApp: mobileApp.id });
}
 
main()
  .then(async () => {
    await prisma.$disconnect();
  })
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });

Ajoutez la commande de seed dans package.json :

{
  "prisma": {
    "seed": "npx tsx prisma/seed.ts"
  }
}

Exécutez le seed :

npx prisma db seed

Vous pouvez vérifier les données avec Prisma Studio :

npx prisma studio

Cela ouvre un navigateur visuel de base de données à http://localhost:5555.


Étape 8 : Construire les définitions de types et la validation

Créez src/lib/validations.ts pour les schémas Zod partagés :

import { z } from "zod";
 
export const createProjectSchema = z.object({
  name: z.string().min(1, "Le nom est requis").max(100),
  description: z.string().max(500).optional(),
  color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Format de couleur invalide"),
});
 
export const createTaskSchema = z.object({
  title: z.string().min(1, "Le titre est requis").max(200),
  description: z.string().max(1000).optional(),
  status: z.enum(["TODO", "IN_PROGRESS", "DONE"]).default("TODO"),
  priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
  dueDate: z.string().optional(),
  projectId: z.string().min(1, "Le projet est requis"),
});
 
export const updateTaskSchema = createTaskSchema.partial().extend({
  id: z.string().min(1),
});
 
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type CreateTaskInput = z.infer<typeof createTaskSchema>;
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>;

Étape 9 : Créer les Server Actions

Les Server Actions sont la méthode recommandée pour gérer les mutations dans Next.js App Router. Créez src/app/actions.ts :

"use server";
 
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import {
  createProjectSchema,
  createTaskSchema,
  updateTaskSchema,
} from "@/lib/validations";
 
// ─── Actions Projets ─────────────────────────────────────
 
export async function createProject(formData: FormData) {
  const raw = {
    name: formData.get("name") as string,
    description: formData.get("description") as string,
    color: formData.get("color") as string,
  };
 
  const validated = createProjectSchema.safeParse(raw);
  if (!validated.success) {
    return { error: validated.error.flatten().fieldErrors };
  }
 
  await prisma.project.create({
    data: validated.data,
  });
 
  revalidatePath("/");
  return { success: true };
}
 
export async function deleteProject(id: string) {
  await prisma.project.delete({
    where: { id },
  });
 
  revalidatePath("/");
  return { success: true };
}
 
// ─── Actions Tâches ───────────────────────────────────────
 
export async function createTask(formData: FormData) {
  const raw = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
    status: formData.get("status") as string,
    priority: formData.get("priority") as string,
    dueDate: formData.get("dueDate") as string,
    projectId: formData.get("projectId") as string,
  };
 
  const validated = createTaskSchema.safeParse(raw);
  if (!validated.success) {
    return { error: validated.error.flatten().fieldErrors };
  }
 
  const { dueDate, ...rest } = validated.data;
 
  await prisma.task.create({
    data: {
      ...rest,
      dueDate: dueDate ? new Date(dueDate) : null,
    },
  });
 
  revalidatePath("/");
  return { success: true };
}
 
export async function updateTaskStatus(id: string, status: string) {
  const validated = updateTaskSchema.safeParse({ id, status });
  if (!validated.success) {
    return { error: "Entrée invalide" };
  }
 
  await prisma.task.update({
    where: { id },
    data: { status: validated.data.status },
  });
 
  revalidatePath("/");
  return { success: true };
}
 
export async function deleteTask(id: string) {
  await prisma.task.delete({
    where: { id },
  });
 
  revalidatePath("/");
  return { success: true };
}

Les patterns clés ici :

  • "use server" marque le fichier comme contenant des Server Actions
  • Validation Zod assure l'intégrité des données avant toute opération en base
  • revalidatePath("/") vide le cache pour que l'interface se mette à jour immédiatement
  • Valeurs de retour typées permettent au client de gérer les états de succès et d'erreur

Étape 10 : Construire la page liste des projets

Créez la page principale dans src/app/page.tsx :

import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { deleteProject } from "./actions";
 
export default async function HomePage() {
  const projects = await prisma.project.findMany({
    include: {
      _count: {
        select: { tasks: true },
      },
      tasks: {
        select: { status: true },
      },
    },
    orderBy: { createdAt: "desc" },
  });
 
  return (
    <main className="max-w-4xl mx-auto p-8">
      <div className="flex items-center justify-between mb-8">
        <h1 className="text-3xl font-bold">Projets</h1>
        <Link
          href="/projects/new"
          className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
        >
          Nouveau Projet
        </Link>
      </div>
 
      <div className="grid gap-4">
        {projects.map((project) => {
          const done = project.tasks.filter((t) => t.status === "DONE").length;
          const total = project._count.tasks;
          const progress = total > 0 ? Math.round((done / total) * 100) : 0;
 
          return (
            <Link
              key={project.id}
              href={`/projects/${project.id}`}
              className="block border rounded-lg p-6 hover:shadow-md transition-shadow"
            >
              <div className="flex items-start justify-between">
                <div className="flex items-center gap-3">
                  <div
                    className="w-4 h-4 rounded-full"
                    style={{ backgroundColor: project.color }}
                  />
                  <div>
                    <h2 className="text-xl font-semibold">{project.name}</h2>
                    {project.description && (
                      <p className="text-gray-500 mt-1">
                        {project.description}
                      </p>
                    )}
                  </div>
                </div>
                <span className="text-sm text-gray-400">
                  {total} tâche{total !== 1 ? "s" : ""}
                </span>
              </div>
 
              {total > 0 && (
                <div className="mt-4">
                  <div className="flex justify-between text-sm mb-1">
                    <span className="text-gray-500">Progression</span>
                    <span className="font-medium">{progress}%</span>
                  </div>
                  <div className="w-full bg-gray-200 rounded-full h-2">
                    <div
                      className="h-2 rounded-full transition-all"
                      style={{
                        width: `${progress}%`,
                        backgroundColor: project.color,
                      }}
                    />
                  </div>
                </div>
              )}
            </Link>
          );
        })}
      </div>
 
      {projects.length === 0 && (
        <div className="text-center py-12 text-gray-500">
          <p className="text-lg">Aucun projet pour le moment</p>
          <p className="mt-2">Créez votre premier projet pour commencer.</p>
        </div>
      )}
    </main>
  );
}

Remarquez comment les requêtes Prisma s'exécutent directement dans le Server Component — pas besoin de routes API. L'option include vous permet de récupérer les données liées et les agrégats en une seule requête.


Étape 11 : Construire la page détail du projet

Créez src/app/projects/[id]/page.tsx :

import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { TaskList } from "./task-list";
import { TaskForm } from "./task-form";
 
interface Props {
  params: Promise<{ id: string }>;
}
 
export default async function ProjectPage({ params }: Props) {
  const { id } = await params;
 
  const project = await prisma.project.findUnique({
    where: { id },
    include: {
      tasks: {
        orderBy: [
          { status: "asc" },
          { priority: "desc" },
          { createdAt: "desc" },
        ],
      },
    },
  });
 
  if (!project) {
    notFound();
  }
 
  const tasksByStatus = {
    TODO: project.tasks.filter((t) => t.status === "TODO"),
    IN_PROGRESS: project.tasks.filter((t) => t.status === "IN_PROGRESS"),
    DONE: project.tasks.filter((t) => t.status === "DONE"),
  };
 
  return (
    <main className="max-w-5xl mx-auto p-8">
      <div className="mb-8">
        <div className="flex items-center gap-3 mb-2">
          <div
            className="w-5 h-5 rounded-full"
            style={{ backgroundColor: project.color }}
          />
          <h1 className="text-3xl font-bold">{project.name}</h1>
        </div>
        {project.description && (
          <p className="text-gray-500 text-lg">{project.description}</p>
        )}
      </div>
 
      <TaskForm projectId={project.id} />
 
      <div className="grid md:grid-cols-3 gap-6 mt-8">
        <TaskColumn title="À faire" tasks={tasksByStatus.TODO} color="#6b7280" />
        <TaskColumn
          title="En cours"
          tasks={tasksByStatus.IN_PROGRESS}
          color="#f59e0b"
        />
        <TaskColumn title="Terminé" tasks={tasksByStatus.DONE} color="#10b981" />
      </div>
    </main>
  );
}
 
function TaskColumn({
  title,
  tasks,
  color,
}: {
  title: string;
  tasks: any[];
  color: string;
}) {
  return (
    <div>
      <div className="flex items-center gap-2 mb-4">
        <div
          className="w-3 h-3 rounded-full"
          style={{ backgroundColor: color }}
        />
        <h2 className="font-semibold text-lg">{title}</h2>
        <span className="text-sm text-gray-400 ml-auto">{tasks.length}</span>
      </div>
      <TaskList tasks={tasks} />
    </div>
  );
}

Étape 12 : Construire les composants de tâches

Créez le composant liste de tâches dans src/app/projects/[id]/task-list.tsx :

"use client";
 
import { updateTaskStatus, deleteTask } from "@/app/actions";
import { useTransition } from "react";
 
interface Task {
  id: string;
  title: string;
  description: string | null;
  status: string;
  priority: string;
  dueDate: string | null;
}
 
const priorityColors: Record<string, string> = {
  LOW: "bg-gray-100 text-gray-600",
  MEDIUM: "bg-blue-100 text-blue-600",
  HIGH: "bg-orange-100 text-orange-600",
  URGENT: "bg-red-100 text-red-600",
};
 
export function TaskList({ tasks }: { tasks: Task[] }) {
  return (
    <div className="space-y-3">
      {tasks.map((task) => (
        <TaskCard key={task.id} task={task} />
      ))}
      {tasks.length === 0 && (
        <p className="text-gray-400 text-sm text-center py-4">Aucune tâche</p>
      )}
    </div>
  );
}
 
function TaskCard({ task }: { task: Task }) {
  const [isPending, startTransition] = useTransition();
 
  const handleStatusChange = (newStatus: string) => {
    startTransition(() => {
      updateTaskStatus(task.id, newStatus);
    });
  };
 
  const handleDelete = () => {
    startTransition(() => {
      deleteTask(task.id);
    });
  };
 
  return (
    <div
      className={`border rounded-lg p-4 bg-white ${
        isPending ? "opacity-50" : ""
      }`}
    >
      <div className="flex items-start justify-between">
        <h3 className="font-medium">{task.title}</h3>
        <button
          onClick={handleDelete}
          className="text-gray-400 hover:text-red-500 text-sm"
        >
          &times;
        </button>
      </div>
 
      {task.description && (
        <p className="text-gray-500 text-sm mt-1">{task.description}</p>
      )}
 
      <div className="flex items-center gap-2 mt-3">
        <span
          className={`text-xs px-2 py-1 rounded-full ${
            priorityColors[task.priority]
          }`}
        >
          {task.priority}
        </span>
 
        {task.dueDate && (
          <span className="text-xs text-gray-400">
            Échéance {new Date(task.dueDate).toLocaleDateString("fr-FR")}
          </span>
        )}
      </div>
 
      <div className="flex gap-1 mt-3">
        {["TODO", "IN_PROGRESS", "DONE"].map((status) => (
          <button
            key={status}
            onClick={() => handleStatusChange(status)}
            disabled={task.status === status}
            className={`text-xs px-2 py-1 rounded ${
              task.status === status
                ? "bg-gray-900 text-white"
                : "bg-gray-100 hover:bg-gray-200 text-gray-600"
            }`}
          >
            {status.replace("_", " ")}
          </button>
        ))}
      </div>
    </div>
  );
}

Créez le formulaire de tâche dans src/app/projects/[id]/task-form.tsx :

"use client";
 
import { createTask } from "@/app/actions";
import { useRef } from "react";
import { useActionState } from "react";
 
const initialState = { error: null as any, success: false };
 
export function TaskForm({ projectId }: { projectId: string }) {
  const formRef = useRef<HTMLFormElement>(null);
 
  async function action(_prev: typeof initialState, formData: FormData) {
    formData.set("projectId", projectId);
    const result = await createTask(formData);
    if (result.success) {
      formRef.current?.reset();
    }
    return result as typeof initialState;
  }
 
  const [state, formAction, isPending] = useActionState(action, initialState);
 
  return (
    <form ref={formRef} action={formAction} className="border rounded-lg p-4">
      <h3 className="font-semibold mb-3">Ajouter une nouvelle tâche</h3>
 
      <div className="grid md:grid-cols-2 gap-3">
        <input
          name="title"
          placeholder="Titre de la tâche"
          required
          className="border rounded px-3 py-2"
        />
 
        <select name="priority" className="border rounded px-3 py-2">
          <option value="LOW">Priorité basse</option>
          <option value="MEDIUM" selected>Priorité moyenne</option>
          <option value="HIGH">Priorité haute</option>
          <option value="URGENT">Urgent</option>
        </select>
 
        <input
          name="description"
          placeholder="Description (optionnel)"
          className="border rounded px-3 py-2"
        />
 
        <input
          name="dueDate"
          type="date"
          className="border rounded px-3 py-2"
        />
      </div>
 
      <input type="hidden" name="status" value="TODO" />
 
      {state.error && (
        <p className="text-red-500 text-sm mt-2">
          {Object.values(state.error).flat().join(", ")}
        </p>
      )}
 
      <button
        type="submit"
        disabled={isPending}
        className="mt-3 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? "Ajout en cours..." : "Ajouter la tâche"}
      </button>
    </form>
  );
}

Étape 13 : Requêtes Prisma avancées

Prisma excelle dans les requêtes complexes. Voici des patterns que vous utiliserez fréquemment :

Filtrage et pagination

// Récupérer les tâches avec des filtres
const tasks = await prisma.task.findMany({
  where: {
    projectId: "some-id",
    status: "IN_PROGRESS",
    priority: { in: ["HIGH", "URGENT"] },
    dueDate: { lte: new Date() }, // tâches en retard
  },
  orderBy: { priority: "desc" },
  skip: 0,
  take: 20,
});

Agrégations

// Compter les tâches par statut par projet
const stats = await prisma.task.groupBy({
  by: ["projectId", "status"],
  _count: { id: true },
});

Écritures imbriquées (Transactions)

// Créer un projet avec des tâches en une seule transaction
const project = await prisma.project.create({
  data: {
    name: "Nouveau Projet",
    tasks: {
      createMany: {
        data: [
          { title: "Tâche 1", priority: "HIGH" },
          { title: "Tâche 2", priority: "MEDIUM" },
        ],
      },
    },
  },
  include: { tasks: true },
});

Transactions interactives

// Transférer toutes les tâches d'un projet à un autre
await prisma.$transaction(async (tx) => {
  const tasks = await tx.task.findMany({
    where: { projectId: sourceId },
  });
 
  await tx.task.updateMany({
    where: { projectId: sourceId },
    data: { projectId: targetId },
  });
 
  await tx.project.delete({
    where: { id: sourceId },
  });
 
  return tasks.length;
});

Étape 14 : Ajouter la recherche et le filtrage

Créez un composant de recherche dans src/app/search.tsx :

"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
 
export function SearchBar() {
  const router = useRouter();
  const searchParams = useSearchParams();
 
  const updateFilter = useCallback(
    (key: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      if (value) {
        params.set(key, value);
      } else {
        params.delete(key);
      }
      router.push(`/?${params.toString()}`);
    },
    [router, searchParams]
  );
 
  return (
    <div className="flex gap-3 mb-6">
      <input
        type="search"
        placeholder="Rechercher des projets..."
        defaultValue={searchParams.get("q") ?? ""}
        onChange={(e) => updateFilter("q", e.target.value)}
        className="border rounded px-3 py-2 flex-1"
      />
      <select
        defaultValue={searchParams.get("sort") ?? "newest"}
        onChange={(e) => updateFilter("sort", e.target.value)}
        className="border rounded px-3 py-2"
      >
        <option value="newest">Plus récents</option>
        <option value="oldest">Plus anciens</option>
        <option value="name">Par nom</option>
      </select>
    </div>
  );
}

Mettez à jour la page principale pour utiliser les paramètres de recherche :

interface Props {
  searchParams: Promise<{ q?: string; sort?: string }>;
}
 
export default async function HomePage({ searchParams }: Props) {
  const { q, sort } = await searchParams;
 
  const projects = await prisma.project.findMany({
    where: q
      ? {
          OR: [
            { name: { contains: q, mode: "insensitive" } },
            { description: { contains: q, mode: "insensitive" } },
          ],
        }
      : undefined,
    include: {
      _count: { select: { tasks: true } },
      tasks: { select: { status: true } },
    },
    orderBy:
      sort === "name"
        ? { name: "asc" }
        : sort === "oldest"
        ? { createdAt: "asc" }
        : { createdAt: "desc" },
  });
 
  // ... reste du composant
}

Étape 15 : Meilleures pratiques de production

Pooling de connexions

Pour les environnements serverless, configurez le pooling de connexions dans votre schéma :

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

Définissez DATABASE_URL vers une chaîne de connexion poolée (PgBouncer, pooler Neon, ou Prisma Accelerate) et DIRECT_URL vers la connexion directe (utilisée uniquement pour les migrations).

Prisma Accelerate

Pour les déploiements edge, activez Prisma Accelerate :

npm install @prisma/extension-accelerate

Mettez à jour votre client :

import { PrismaClient } from "@prisma/client";
import { withAccelerate } from "@prisma/extension-accelerate";
 
export const prisma = new PrismaClient().$extends(withAccelerate());

Cela active le cache global et le pooling de connexions en edge.

Logging

Activez le logging des requêtes en développement :

export const prisma = new PrismaClient({
  log:
    process.env.NODE_ENV === "development"
      ? ["query", "error", "warn"]
      : ["error"],
});

Dépannage

"PrismaClientInitializationError: Can't reach database server"

  • Vérifiez votre DATABASE_URL dans .env
  • Assurez-vous que PostgreSQL est en cours d'exécution
  • Vérifiez l'accès réseau (pare-feu, VPN)

Les types ne se mettent pas à jour après des changements de schéma

Exécutez la génération du client manuellement :

npx prisma generate

Cela régénère les types TypeScript depuis votre schéma.

"Unique constraint violation"

Vos données contiennent des valeurs dupliquées dans un champ unique. Soit mettez à jour l'enregistrement en conflit, soit utilisez upsert :

await prisma.project.upsert({
  where: { id: "existing-id" },
  update: { name: "Nom mis à jour" },
  create: { name: "Nouveau Projet" },
});

Dérive de migration en production

Si le schéma de votre base de données a divergé des migrations :

npx prisma migrate resolve --applied "migration_name"

Ou réinitialiser (développement uniquement) :

npx prisma migrate reset

Prochaines étapes

Maintenant que vous avez une application full-stack fonctionnelle avec Prisma et Next.js, explorez ces sujets :

  • Ajouter l'authentification avec NextAuth.js ou Better Auth pour scoper les projets par utilisateur
  • Implémenter les mises à jour en temps réel avec Prisma Pulse pour des tableaux de tâches en direct
  • Ajouter l'upload de fichiers pour les pièces jointes de tâches avec Vercel Blob ou S3
  • Configurer le CI/CD avec GitHub Actions pour exécuter les migrations automatiquement
  • Explorer les extensions Prisma Client pour le middleware, la suppression douce et le logging d'audit

Conclusion

Dans ce tutoriel, vous avez construit une application complète de gestion de projets avec Prisma ORM et Next.js 15 App Router. Vous avez appris à :

  1. Définir un modèle de données avec Prisma Schema Language incluant relations et enums
  2. Exécuter des migrations et seeder la base de données
  3. Créer un singleton Prisma Client pour Next.js
  4. Construire des Server Actions typées pour toutes les opérations CRUD
  5. Utiliser l'API de requête de Prisma pour le filtrage, la pagination et les agrégations
  6. Gérer les écritures imbriquées et les transactions pour les opérations complexes
  7. Implémenter la recherche et le filtrage avec les paramètres de recherche URL
  8. Configurer le déploiement en production avec le pooling de connexions et le support edge

Le schéma déclaratif de Prisma et les types auto-générés éliminent toute une catégorie de bugs — vous ne pouvez pas interroger un champ qui n'existe pas, passer le mauvais type, ou oublier une relation requise. Combiné avec les Server Components et Server Actions de Next.js, vous obtenez une expérience de développement full-stack typée de la base de données jusqu'à l'interface utilisateur.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Construire une application web full-stack avec SvelteKit 2 : guide pratique complet.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes