Construire une Application CRUD Full-Stack avec MongoDB, Mongoose et Next.js 15

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

MongoDB est la base de données NoSQL la plus populaire au monde — et Mongoose la rend typée et élégante en TypeScript. Dans ce tutoriel, vous allez construire une application complète de gestion de tâches depuis zéro avec MongoDB Atlas, Mongoose ODM et Next.js 15 App Router avec Server Actions.

Ce que vous allez apprendre

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

  • Configurer un cluster MongoDB Atlas et le connecter à Next.js
  • Définir des schémas Mongoose avec inférence de types TypeScript
  • Construire des opérations CRUD complètes avec les Server Actions de Next.js
  • Implémenter la validation de formulaires avec Zod
  • Ajouter la recherche et le filtrage avec les requêtes MongoDB
  • Gérer la pagination avec des patterns offset
  • Déployer en production avec les bonnes pratiques de gestion des connexions

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé (node --version)
  • De l'expérience en TypeScript (types, interfaces, async/await)
  • Une familiarité avec Next.js (App Router, Server Components)
  • Un compte MongoDB Atlas (le tier gratuit fonctionne parfaitement)
  • Un éditeur de code — VS Code ou Cursor recommandé

Pourquoi MongoDB + Mongoose ?

MongoDB est une base de données de documents qui stocke les données dans des documents flexibles de type JSON. Combiné avec Mongoose, vous obtenez la validation de schéma, la sécurité des types et une API de requêtes puissante. Voici la comparaison :

FonctionnalitéMongoDB + MongoosePostgreSQL + PrismaSQLite + Drizzle
Modèle de donnéesDocuments (JSON)Tables relationnellesTables relationnelles
SchémaFlexible, optionnelStrict, requisStrict, requis
Sécurité des typesMongoose + TS genericsTypes auto-générésTypes inférés
Mise à l'échelleHorizontale (sharding)Verticale (read replicas)Fichier unique
Données imbriquéesIntégration nativeColonnes JSON ou joinsColonnes JSON ou joins
Tier gratuitAtlas 512 Mo pour toujoursNeon 0.5 GoLocal, illimité

Choisissez MongoDB quand vos données sont naturellement hiérarchiques, que votre schéma évolue fréquemment, ou que vous avez besoin d'une mise à l'échelle horizontale.


Étape 1 : Créer le projet Next.js

Créez un nouveau projet Next.js 15 avec TypeScript et Tailwind CSS :

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

Installez les dépendances MongoDB et Mongoose :

npm install mongoose zod
npm install -D @types/mongoose

La structure de votre projet ressemblera à ceci :

task-manager/
├── src/
│   ├── app/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── tasks/
│   ├── lib/
│   │   ├── mongodb.ts
│   │   └── actions/
│   └── models/
│       └── task.ts
├── .env.local
└── package.json

Étape 2 : Configurer MongoDB Atlas

Créer un cluster gratuit

  1. Allez sur MongoDB Atlas et connectez-vous
  2. Cliquez sur Build a Database et sélectionnez le tier M0 Free
  3. Choisissez votre fournisseur cloud et la région la plus proche de vos utilisateurs
  4. Cliquez sur Create Deployment

Configurer les accès

  1. Créez un utilisateur de base de données avec un nom et un mot de passe
  2. Dans Network Access, ajoutez votre adresse IP ou Allow Access from Anywhere pour le développement
  3. Cliquez sur Connect, sélectionnez Drivers et copiez la chaîne de connexion

Ajouter la chaîne de connexion

Créez un fichier .env.local à la racine du projet :

MONGODB_URI=mongodb+srv://<username>:<password>@<cluster>.mongodb.net/task-manager?retryWrites=true&w=majority

Remplacez <username>, <password> et <cluster> par vos identifiants Atlas réels.


Étape 3 : Créer l'utilitaire de connexion MongoDB

Les connexions MongoDB dans les environnements serverless nécessitent un traitement spécial. Vous devez mettre en cache la connexion pour éviter d'épuiser le pool de connexions.

Créez src/lib/mongodb.ts :

import mongoose from "mongoose";
 
const MONGODB_URI = process.env.MONGODB_URI!;
 
if (!MONGODB_URI) {
  throw new Error("Please define the MONGODB_URI environment variable in .env.local");
}
 
interface MongooseCache {
  conn: typeof mongoose | null;
  promise: Promise<typeof mongoose> | null;
}
 
declare global {
  var mongooseCache: MongooseCache | undefined;
}
 
const cached: MongooseCache = global.mongooseCache ?? { conn: null, promise: null };
 
if (!global.mongooseCache) {
  global.mongooseCache = cached;
}
 
export async function connectDB(): Promise<typeof mongoose> {
  if (cached.conn) {
    return cached.conn;
  }
 
  if (!cached.promise) {
    const opts = {
      bufferCommands: false,
      maxPoolSize: 10,
    };
 
    cached.promise = mongoose.connect(MONGODB_URI, opts).then((m) => m);
  }
 
  cached.conn = await cached.promise;
  return cached.conn;
}

Ce pattern met en cache la connexion entre les rechargements à chaud en développement et entre les invocations de fonctions en production. La variable global survit aux rechargements de modules de Next.js.


Étape 4 : Définir le schéma Mongoose

Les schémas Mongoose définissent la forme de vos documents et fournissent la validation, les valeurs par défaut et les hooks middleware.

Créez src/models/task.ts :

import mongoose, { Schema, Document, Model } from "mongoose";
 
export interface ITask {
  title: string;
  description: string;
  status: "todo" | "in-progress" | "done";
  priority: "low" | "medium" | "high";
  dueDate?: Date;
  tags: string[];
  createdAt: Date;
  updatedAt: Date;
}
 
export interface ITaskDocument extends ITask, Document {}
 
const taskSchema = new Schema<ITaskDocument>(
  {
    title: {
      type: String,
      required: [true, "Title is required"],
      trim: true,
      maxlength: [200, "Title cannot exceed 200 characters"],
    },
    description: {
      type: String,
      required: [true, "Description is required"],
      trim: true,
      maxlength: [2000, "Description cannot exceed 2000 characters"],
    },
    status: {
      type: String,
      enum: ["todo", "in-progress", "done"],
      default: "todo",
    },
    priority: {
      type: String,
      enum: ["low", "medium", "high"],
      default: "medium",
    },
    dueDate: {
      type: Date,
    },
    tags: {
      type: [String],
      default: [],
    },
  },
  {
    timestamps: true,
  }
);
 
taskSchema.index({ status: 1, priority: 1 });
taskSchema.index({ title: "text", description: "text" });
taskSchema.index({ createdAt: -1 });
 
const Task: Model<ITaskDocument> =
  mongoose.models.Task || mongoose.model<ITaskDocument>("Task", taskSchema);
 
export default Task;

Décisions de conception clés :

  • timestamps: true gère automatiquement createdAt et updatedAt
  • Les index sur status et priority accélèrent les requêtes filtrées
  • L'index texte sur title et description active la recherche full-text
  • mongoose.models.Task || empêche la recompilation du modèle pendant le rechargement à chaud

Étape 5 : Créer les schémas de validation Zod

Définissez des schémas de validation qui fonctionnent côté serveur et client.

Créez src/lib/validations/task.ts :

import { z } from "zod";
 
export const createTaskSchema = z.object({
  title: z
    .string()
    .min(1, "Title is required")
    .max(200, "Title cannot exceed 200 characters"),
  description: z
    .string()
    .min(1, "Description is required")
    .max(2000, "Description cannot exceed 2000 characters"),
  status: z.enum(["todo", "in-progress", "done"]).default("todo"),
  priority: z.enum(["low", "medium", "high"]).default("medium"),
  dueDate: z.string().optional().transform((val) => (val ? new Date(val) : undefined)),
  tags: z
    .string()
    .optional()
    .transform((val) => (val ? val.split(",").map((t) => t.trim()).filter(Boolean) : [])),
});
 
export const updateTaskSchema = createTaskSchema.partial();
 
export type CreateTaskInput = z.infer<typeof createTaskSchema>;
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>;

Étape 6 : Construire les Server Actions

Les Server Actions sont la façon la plus propre de gérer les soumissions de formulaires et les mutations de données dans Next.js 15. Elles s'exécutent sur le serveur et peuvent accéder directement à votre base de données.

Créez src/lib/actions/task-actions.ts :

"use server";
 
import { revalidatePath } from "next/cache";
import { connectDB } from "@/lib/mongodb";
import Task from "@/models/task";
import { createTaskSchema, updateTaskSchema } from "@/lib/validations/task";
 
export type ActionState = {
  success: boolean;
  message: string;
  errors?: Record<string, string[]>;
};
 
export async function createTask(
  _prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  await connectDB();
 
  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,
    tags: formData.get("tags") as string,
  };
 
  const result = createTaskSchema.safeParse(raw);
 
  if (!result.success) {
    return {
      success: false,
      message: "La validation a échoué",
      errors: result.error.flatten().fieldErrors,
    };
  }
 
  await Task.create(result.data);
  revalidatePath("/tasks");
 
  return { success: true, message: "Tâche créée avec succès" };
}
 
export async function updateTask(
  id: string,
  _prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  await connectDB();
 
  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,
    tags: formData.get("tags") as string,
  };
 
  const result = updateTaskSchema.safeParse(raw);
 
  if (!result.success) {
    return {
      success: false,
      message: "La validation a échoué",
      errors: result.error.flatten().fieldErrors,
    };
  }
 
  const task = await Task.findByIdAndUpdate(id, result.data, { new: true });
 
  if (!task) {
    return { success: false, message: "Tâche introuvable" };
  }
 
  revalidatePath("/tasks");
  return { success: true, message: "Tâche mise à jour avec succès" };
}
 
export async function deleteTask(id: string): Promise<ActionState> {
  await connectDB();
 
  const task = await Task.findByIdAndDelete(id);
 
  if (!task) {
    return { success: false, message: "Tâche introuvable" };
  }
 
  revalidatePath("/tasks");
  return { success: true, message: "Tâche supprimée avec succès" };
}
 
export async function toggleTaskStatus(
  id: string,
  newStatus: "todo" | "in-progress" | "done"
): Promise<ActionState> {
  await connectDB();
 
  const task = await Task.findByIdAndUpdate(id, { status: newStatus }, { new: true });
 
  if (!task) {
    return { success: false, message: "Tâche introuvable" };
  }
 
  revalidatePath("/tasks");
  return { success: true, message: "Statut mis à jour" };
}

Chaque action suit un pattern cohérent : connecter, valider, exécuter, revalider. Le type ActionState fournit une forme prévisible pour gérer les résultats côté client.


Étape 7 : Construire la couche d'accès aux données

Créez les fonctions de requête pour lire les données. Elles s'exécutent dans les Server Components.

Créez src/lib/actions/task-queries.ts :

import { connectDB } from "@/lib/mongodb";
import Task, { ITaskDocument } from "@/models/task";
 
export interface TaskFilters {
  status?: string;
  priority?: string;
  search?: string;
  page?: number;
  limit?: number;
}
 
export interface PaginatedTasks {
  tasks: SerializedTask[];
  total: number;
  page: number;
  totalPages: number;
}
 
export interface SerializedTask {
  _id: string;
  title: string;
  description: string;
  status: "todo" | "in-progress" | "done";
  priority: "low" | "medium" | "high";
  dueDate: string | null;
  tags: string[];
  createdAt: string;
  updatedAt: string;
}
 
function serializeTask(task: ITaskDocument): SerializedTask {
  return {
    _id: task._id.toString(),
    title: task.title,
    description: task.description,
    status: task.status,
    priority: task.priority,
    dueDate: task.dueDate ? task.dueDate.toISOString() : null,
    tags: task.tags,
    createdAt: task.createdAt.toISOString(),
    updatedAt: task.updatedAt.toISOString(),
  };
}
 
export async function getTasks(filters: TaskFilters = {}): Promise<PaginatedTasks> {
  await connectDB();
 
  const { status, priority, search, page = 1, limit = 10 } = filters;
 
  const query: Record<string, unknown> = {};
 
  if (status && status !== "all") {
    query.status = status;
  }
 
  if (priority && priority !== "all") {
    query.priority = priority;
  }
 
  if (search) {
    query.$text = { $search: search };
  }
 
  const skip = (page - 1) * limit;
 
  const [tasks, total] = await Promise.all([
    Task.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
    Task.countDocuments(query),
  ]);
 
  return {
    tasks: (tasks as unknown as ITaskDocument[]).map(serializeTask),
    total,
    page,
    totalPages: Math.ceil(total / limit),
  };
}
 
export async function getTaskById(id: string): Promise<SerializedTask | null> {
  await connectDB();
 
  const task = await Task.findById(id).lean();
 
  if (!task) return null;
 
  return serializeTask(task as unknown as ITaskDocument);
}
 
export async function getTaskStats() {
  await connectDB();
 
  const [total, todo, inProgress, done] = await Promise.all([
    Task.countDocuments(),
    Task.countDocuments({ status: "todo" }),
    Task.countDocuments({ status: "in-progress" }),
    Task.countDocuments({ status: "done" }),
  ]);
 
  return { total, todo, inProgress, done };
}

La fonction serializeTask convertit les documents Mongoose en objets simples — nécessaire car les Server Components ne peuvent pas passer des instances Mongoose aux Client Components.


Étape 8 : Construire la page de liste des tâches

Créez la page principale des tâches comme Server Component.

Créez src/app/tasks/page.tsx :

import Link from "next/link";
import { getTasks, getTaskStats } from "@/lib/actions/task-queries";
import { TaskCard } from "./task-card";
import { TaskFilters } from "./task-filters";
import { Pagination } from "./pagination";
 
interface PageProps {
  searchParams: Promise<{
    status?: string;
    priority?: string;
    search?: string;
    page?: string;
  }>;
}
 
export default async function TasksPage({ searchParams }: PageProps) {
  const params = await searchParams;
  const page = parseInt(params.page || "1", 10);
 
  const [{ tasks, total, totalPages }, stats] = await Promise.all([
    getTasks({
      status: params.status,
      priority: params.priority,
      search: params.search,
      page,
      limit: 10,
    }),
    getTaskStats(),
  ]);
 
  return (
    <div className="max-w-5xl mx-auto px-4 py-8">
      <div className="flex items-center justify-between mb-8">
        <div>
          <h1 className="text-3xl font-bold">Tâches</h1>
          <p className="text-gray-500 mt-1">
            {stats.total} total &middot; {stats.todo} à faire &middot;{" "}
            {stats.inProgress} en cours &middot; {stats.done} terminées
          </p>
        </div>
        <Link
          href="/tasks/new"
          className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
        >
          Nouvelle tâche
        </Link>
      </div>
 
      <TaskFilters />
 
      {tasks.length === 0 ? (
        <div className="text-center py-16 text-gray-500">
          <p className="text-lg">Aucune tâche trouvée</p>
          <p className="mt-2">Créez votre première tâche pour commencer.</p>
        </div>
      ) : (
        <div className="space-y-4 mt-6">
          {tasks.map((task) => (
            <TaskCard key={task._id} task={task} />
          ))}
        </div>
      )}
 
      {totalPages > 1 && (
        <Pagination currentPage={page} totalPages={totalPages} />
      )}
    </div>
  );
}

Étape 9 : Construire le composant carte de tâche

Créez un Client Component pour chaque tâche avec basculement de statut et actions de suppression.

Créez src/app/tasks/task-card.tsx :

"use client";
 
import { useTransition } from "react";
import Link from "next/link";
import { deleteTask, toggleTaskStatus } from "@/lib/actions/task-actions";
import type { SerializedTask } from "@/lib/actions/task-queries";
 
const statusColors = {
  todo: "bg-gray-100 text-gray-800",
  "in-progress": "bg-blue-100 text-blue-800",
  done: "bg-green-100 text-green-800",
};
 
const priorityColors = {
  low: "bg-slate-100 text-slate-700",
  medium: "bg-yellow-100 text-yellow-800",
  high: "bg-red-100 text-red-800",
};
 
const nextStatus: Record<string, "todo" | "in-progress" | "done"> = {
  todo: "in-progress",
  "in-progress": "done",
  done: "todo",
};
 
export function TaskCard({ task }: { task: SerializedTask }) {
  const [isPending, startTransition] = useTransition();
 
  const handleStatusToggle = () => {
    startTransition(async () => {
      await toggleTaskStatus(task._id, nextStatus[task.status]);
    });
  };
 
  const handleDelete = () => {
    if (!confirm("Êtes-vous sûr de vouloir supprimer cette tâche ?")) return;
    startTransition(async () => {
      await deleteTask(task._id);
    });
  };
 
  return (
    <div
      className={`border rounded-lg p-4 transition ${
        isPending ? "opacity-50" : ""
      }`}
    >
      <div className="flex items-start justify-between">
        <div className="flex-1">
          <div className="flex items-center gap-2 mb-2">
            <button
              onClick={handleStatusToggle}
              className={`px-2 py-1 rounded-full text-xs font-medium ${
                statusColors[task.status]
              }`}
            >
              {task.status}
            </button>
            <span
              className={`px-2 py-1 rounded-full text-xs font-medium ${
                priorityColors[task.priority]
              }`}
            >
              {task.priority}
            </span>
          </div>
          <Link href={`/tasks/${task._id}`} className="group">
            <h3 className="text-lg font-semibold group-hover:text-blue-600 transition">
              {task.title}
            </h3>
          </Link>
          <p className="text-gray-600 mt-1 line-clamp-2">{task.description}</p>
          {task.tags.length > 0 && (
            <div className="flex gap-1 mt-2">
              {task.tags.map((tag) => (
                <span
                  key={tag}
                  className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded text-xs"
                >
                  {tag}
                </span>
              ))}
            </div>
          )}
        </div>
        <div className="flex items-center gap-2 ml-4">
          <Link
            href={`/tasks/${task._id}/edit`}
            className="text-gray-400 hover:text-blue-600 transition"
          >
            Modifier
          </Link>
          <button
            onClick={handleDelete}
            className="text-gray-400 hover:text-red-600 transition"
          >
            Supprimer
          </button>
        </div>
      </div>
    </div>
  );
}

Étape 10 : Construire le formulaire de tâche

Créez un composant formulaire réutilisable pour la création et la modification de tâches.

Créez src/app/tasks/task-form.tsx :

"use client";
 
import { useActionState } from "react";
import { createTask, updateTask, ActionState } from "@/lib/actions/task-actions";
import type { SerializedTask } from "@/lib/actions/task-queries";
 
const initialState: ActionState = {
  success: false,
  message: "",
};
 
export function TaskForm({ task }: { task?: SerializedTask }) {
  const action = task
    ? updateTask.bind(null, task._id)
    : createTask;
 
  const [state, formAction, isPending] = useActionState(action, initialState);
 
  return (
    <form action={formAction} className="space-y-6 max-w-2xl">
      {state.message && !state.success && (
        <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
          {state.message}
        </div>
      )}
 
      {state.success && (
        <div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
          {state.message}
        </div>
      )}
 
      <div>
        <label htmlFor="title" className="block text-sm font-medium mb-1">
          Titre
        </label>
        <input
          id="title"
          name="title"
          type="text"
          defaultValue={task?.title}
          required
          className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          placeholder="Entrez le titre de la tâche"
        />
        {state.errors?.title && (
          <p className="text-red-500 text-sm mt-1">{state.errors.title[0]}</p>
        )}
      </div>
 
      <div>
        <label htmlFor="description" className="block text-sm font-medium mb-1">
          Description
        </label>
        <textarea
          id="description"
          name="description"
          defaultValue={task?.description}
          required
          rows={4}
          className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          placeholder="Décrivez la tâche"
        />
        {state.errors?.description && (
          <p className="text-red-500 text-sm mt-1">
            {state.errors.description[0]}
          </p>
        )}
      </div>
 
      <div className="grid grid-cols-2 gap-4">
        <div>
          <label htmlFor="status" className="block text-sm font-medium mb-1">
            Statut
          </label>
          <select
            id="status"
            name="status"
            defaultValue={task?.status || "todo"}
            className="w-full border rounded-lg px-3 py-2"
          >
            <option value="todo">À faire</option>
            <option value="in-progress">En cours</option>
            <option value="done">Terminée</option>
          </select>
        </div>
 
        <div>
          <label htmlFor="priority" className="block text-sm font-medium mb-1">
            Priorité
          </label>
          <select
            id="priority"
            name="priority"
            defaultValue={task?.priority || "medium"}
            className="w-full border rounded-lg px-3 py-2"
          >
            <option value="low">Basse</option>
            <option value="medium">Moyenne</option>
            <option value="high">Haute</option>
          </select>
        </div>
      </div>
 
      <div>
        <label htmlFor="dueDate" className="block text-sm font-medium mb-1">
          Date d'échéance (optionnelle)
        </label>
        <input
          id="dueDate"
          name="dueDate"
          type="date"
          defaultValue={task?.dueDate ? task.dueDate.split("T")[0] : ""}
          className="w-full border rounded-lg px-3 py-2"
        />
      </div>
 
      <div>
        <label htmlFor="tags" className="block text-sm font-medium mb-1">
          Tags (séparés par des virgules)
        </label>
        <input
          id="tags"
          name="tags"
          type="text"
          defaultValue={task?.tags.join(", ")}
          className="w-full border rounded-lg px-3 py-2"
          placeholder="frontend, urgent, bug"
        />
      </div>
 
      <button
        type="submit"
        disabled={isPending}
        className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition"
      >
        {isPending
          ? task
            ? "Mise à jour..."
            : "Création..."
          : task
          ? "Mettre à jour la tâche"
          : "Créer la tâche"}
      </button>
    </form>
  );
}

Étape 11 : Construire le composant de filtres

Créez src/app/tasks/task-filters.tsx :

"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useState } from "react";
 
export function TaskFilters() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [search, setSearch] = useState(searchParams.get("search") || "");
 
  const updateFilter = useCallback(
    (key: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      if (value && value !== "all") {
        params.set(key, value);
      } else {
        params.delete(key);
      }
      params.delete("page");
      router.push(`/tasks?${params.toString()}`);
    },
    [router, searchParams]
  );
 
  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault();
    updateFilter("search", search);
  };
 
  return (
    <div className="flex flex-wrap items-center gap-4">
      <form onSubmit={handleSearch} className="flex gap-2">
        <input
          type="text"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          placeholder="Rechercher des tâches..."
          className="border rounded-lg px-3 py-2 w-64"
        />
        <button
          type="submit"
          className="bg-gray-100 px-3 py-2 rounded-lg hover:bg-gray-200 transition"
        >
          Rechercher
        </button>
      </form>
 
      <select
        value={searchParams.get("status") || "all"}
        onChange={(e) => updateFilter("status", e.target.value)}
        className="border rounded-lg px-3 py-2"
      >
        <option value="all">Tous les statuts</option>
        <option value="todo">À faire</option>
        <option value="in-progress">En cours</option>
        <option value="done">Terminée</option>
      </select>
 
      <select
        value={searchParams.get("priority") || "all"}
        onChange={(e) => updateFilter("priority", e.target.value)}
        className="border rounded-lg px-3 py-2"
      >
        <option value="all">Toutes les priorités</option>
        <option value="low">Basse</option>
        <option value="medium">Moyenne</option>
        <option value="high">Haute</option>
      </select>
    </div>
  );
}

Étape 12 : Construire les pages de création et modification

Créez src/app/tasks/new/page.tsx :

import Link from "next/link";
import { TaskForm } from "../task-form";
 
export default function NewTaskPage() {
  return (
    <div className="max-w-5xl mx-auto px-4 py-8">
      <div className="mb-8">
        <Link href="/tasks" className="text-blue-600 hover:underline">
          &larr; Retour aux tâches
        </Link>
        <h1 className="text-3xl font-bold mt-4">Créer une nouvelle tâche</h1>
      </div>
      <TaskForm />
    </div>
  );
}

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

import Link from "next/link";
import { notFound } from "next/navigation";
import { getTaskById } from "@/lib/actions/task-queries";
import { TaskForm } from "../../task-form";
 
interface PageProps {
  params: Promise<{ id: string }>;
}
 
export default async function EditTaskPage({ params }: PageProps) {
  const { id } = await params;
  const task = await getTaskById(id);
 
  if (!task) notFound();
 
  return (
    <div className="max-w-5xl mx-auto px-4 py-8">
      <div className="mb-8">
        <Link href="/tasks" className="text-blue-600 hover:underline">
          &larr; Retour aux tâches
        </Link>
        <h1 className="text-3xl font-bold mt-4">Modifier la tâche</h1>
      </div>
      <TaskForm task={task} />
    </div>
  );
}

Étape 13 : Patterns avancés

Pipelines d'agrégation

Le framework d'agrégation de MongoDB est puissant pour les analyses. Voici comment obtenir les taux de complétion des tâches par priorité :

export async function getCompletionRatesByPriority() {
  await connectDB();
 
  const results = await Task.aggregate([
    {
      $group: {
        _id: "$priority",
        total: { $sum: 1 },
        completed: {
          $sum: { $cond: [{ $eq: ["$status", "done"] }, 1, 0] },
        },
      },
    },
    {
      $project: {
        priority: "$_id",
        total: 1,
        completed: 1,
        rate: {
          $round: [{ $multiply: [{ $divide: ["$completed", "$total"] }, 100] }, 1],
        },
      },
    },
    { $sort: { rate: -1 } },
  ]);
 
  return results;
}

Middleware Mongoose (Hooks)

Ajoutez des hooks pre/post pour les opérations courantes :

taskSchema.pre("save", function (next) {
  if (this.isModified("status") && this.status === "done") {
    this.set("completedAt", new Date());
  }
  next();
});

Champs virtuels

Ajoutez des champs calculés sans les stocker :

taskSchema.virtual("isOverdue").get(function () {
  if (!this.dueDate || this.status === "done") return false;
  return new Date() > this.dueDate;
});
 
taskSchema.set("toJSON", { virtuals: true });

Bonnes pratiques pour la production

1. Gestion du pool de connexions

L'utilitaire de connexion que nous avons construit gère le pooling, mais ajustez-le pour la production :

const opts = {
  bufferCommands: false,
  maxPoolSize: 10,
  serverSelectionTimeoutMS: 5000,
  socketTimeoutMS: 45000,
};

2. Gestion des index

Vérifiez toujours que vos index sont utilisés :

db.tasks.find({ status: "todo" }).explain("executionStats")

3. Gestion des erreurs

Encapsulez les opérations de base de données avec une gestion d'erreurs appropriée :

export async function createTask(/* ... */): Promise<ActionState> {
  try {
    await connectDB();
    // ... opération
  } catch (error) {
    if (error instanceof mongoose.Error.ValidationError) {
      return { success: false, message: "Données invalides fournies" };
    }
    return { success: false, message: "Une erreur inattendue est survenue" };
  }
}

4. Déployer sur Vercel

MongoDB Atlas fonctionne parfaitement avec les fonctions serverless de Vercel :

npm i -g vercel
vercel
vercel env add MONGODB_URI

Dépannage

"MongoServerError: bad auth"

Les identifiants de votre chaîne de connexion sont incorrects. Vérifiez le nom d'utilisateur et le mot de passe dans Atlas sous Database Access.

"MongooseServerSelectionError: connection timed out"

Votre IP n'est pas autorisée. Allez dans Atlas Network Access et ajoutez votre IP actuelle.

"OverwriteModelError: Cannot overwrite model"

Cela arrive pendant le rechargement à chaud. Le pattern mongoose.models.Task || dans le fichier modèle empêche cela.

"buffering timed out after 10000ms"

La connexion a échoué silencieusement. Assurez-vous que MONGODB_URI est défini et que le cluster fonctionne.


Prochaines étapes

Maintenant que vous avez une application MongoDB + Next.js fonctionnelle, explorez ces améliorations :

  • Authentification — Ajoutez des comptes utilisateurs avec NextAuth.js
  • Mises à jour en temps réel — Utilisez MongoDB Change Streams avec les Server-Sent Events
  • Pièces jointes — Stockez les fichiers dans MongoDB GridFS ou UploadThing
  • Recherche avancée — Passez à MongoDB Atlas Search pour la correspondance floue
  • Cache — Ajoutez un cache Redis

Conclusion

Vous avez construit une application CRUD full-stack complète avec MongoDB Atlas, Mongoose et Next.js 15 App Router. L'application inclut des schémas typés, des Server Actions pour les mutations, la validation Zod, la recherche full-text, le filtrage, la pagination et une gestion des connexions prête pour la production.

Le modèle de documents flexible de MongoDB le rend idéal pour le prototypage rapide et les applications avec des schémas évolutifs. Combiné avec la couche de validation de Mongoose et les Server Actions de Next.js, vous obtenez une expérience de développement productive et typée avec un minimum de code répétitif.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Construire une application full-stack en temps réel avec Convex et Next.js 15.

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