Construire une application full-stack en temps réel avec Convex et Next.js 15

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

Convex est un backend-as-a-service (BaaS) réactif qui remplace votre base de données, vos fonctions serveur et votre infrastructure temps réel par une seule plateforme native TypeScript. Contrairement aux backends traditionnels où vous devez connecter des endpoints REST, des couches ORM et des serveurs WebSocket séparément, Convex vous offre une base de données réactive qui pousse automatiquement les mises à jour vers chaque client connecté dès que les données changent.

Dans ce tutoriel, vous allez construire une application de prise de notes collaborative en temps réel — pensez à une version simplifiée de Notion où plusieurs utilisateurs peuvent créer, modifier et organiser des notes qui se synchronisent instantanément dans tous les navigateurs. Vous maîtriserez les schémas Convex, les requêtes, les mutations, les abonnements en temps réel, le téléchargement de fichiers et l'authentification avec Clerk.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé
  • Un compte Convex gratuit — inscrivez-vous sur convex.dev
  • Des connaissances de base en React et TypeScript
  • Une familiarité avec le Next.js App Router (layouts, composants serveur, composants client)
  • Un éditeur de code (VS Code recommandé)

Ce que vous allez construire

Une application de notes collaborative avec ces fonctionnalités :

  • Synchronisation en temps réel des notes — les modifications apparaissent instantanément sur tous les clients connectés
  • Gestion complète des notes — créer, modifier, supprimer, épingler et rechercher des notes
  • Pièces jointes — télécharger des images et documents vers les notes via le stockage Convex
  • Authentification des utilisateurs — connexion avec Clerk, données isolées par utilisateur
  • Sécurité de types de bout en bout — du schéma de base de données aux composants React, tout est typé

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

Commencez par créer un nouveau projet Next.js 15 avec TypeScript et Tailwind CSS :

npx create-next-app@latest convex-notes --typescript --tailwind --eslint --app --src-dir --use-npm
cd convex-notes

Acceptez les options par défaut lorsque demandé. Cela crée un projet Next.js avec le App Router et la structure de répertoire src/.

Étape 2 : Installer Convex

Installez la bibliothèque client Convex et initialisez votre projet :

npm install convex
npx convex init

La commande convex init crée un répertoire convex/ à la racine de votre projet. C'est là que réside tout votre code backend — définitions de schémas, requêtes, mutations et actions. Convex crée également un fichier .env.local avec votre NEXT_PUBLIC_CONVEX_URL automatiquement.

La structure de votre projet ressemble maintenant à ceci :

convex-notes/
├── convex/           # Code backend (exécuté sur les serveurs Convex)
│   ├── _generated/   # Types et références API auto-générés
│   └── tsconfig.json
├── src/
│   └── app/          # Pages Next.js App Router
├── .env.local        # NEXT_PUBLIC_CONVEX_URL
└── package.json

Étape 3 : Définir le schéma de base de données

Convex utilise un système de schéma TypeScript-first. Chaque table et champ est défini avec des validateurs qui fournissent à la fois la validation au runtime et l'inférence de types à la compilation.

Créez le fichier de schéma dans convex/schema.ts :

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
 
export default defineSchema({
  notes: defineTable({
    userId: v.string(),
    title: v.string(),
    content: v.string(),
    isPinned: v.boolean(),
    fileId: v.optional(v.id("_file_storage")),
    fileName: v.optional(v.string()),
  })
    .index("by_user", ["userId"])
    .index("by_user_pinned", ["userId", "isPinned"])
    .searchIndex("search_notes", {
      searchField: "content",
      filterFields: ["userId"],
    }),
});

Concepts clés ici :

  • Validateurs vv.string(), v.boolean(), v.optional() et v.id() définissent les types de champs avec validation au runtime
  • Indexby_user permet des requêtes efficaces filtrées par userId. Convex exige de déclarer les index à l'avance
  • Index de recherchesearch_notes permet la recherche textuelle complète sur le champ content
  • _file_storage — une table Convex intégrée pour les fichiers téléchargés

Poussez le schéma vers votre déploiement Convex :

npx convex dev

Gardez cette commande en cours d'exécution dans un terminal — elle surveille les modifications et déploie automatiquement votre code backend. C'est l'une des meilleures fonctionnalités de Convex : le hot-reload pour votre backend.

Étape 4 : Écrire les fonctions de requête

Les requêtes Convex sont des fonctions TypeScript qui s'exécutent sur le serveur et se réexécutent automatiquement lorsque les données sous-jacentes changent. Les clients connectés reçoivent les mises à jour en temps réel sans aucun polling ni configuration WebSocket.

Créez convex/notes.ts :

import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
 
// Lister toutes les notes de l'utilisateur actuel
export const list = query({
  args: { userId: v.string() },
  returns: v.array(
    v.object({
      _id: v.id("notes"),
      _creationTime: v.number(),
      userId: v.string(),
      title: v.string(),
      content: v.string(),
      isPinned: v.boolean(),
      fileId: v.optional(v.id("_file_storage")),
      fileName: v.optional(v.string()),
    })
  ),
  handler: async (ctx, args) => {
    const notes = await ctx.db
      .query("notes")
      .withIndex("by_user", (q) => q.eq("userId", args.userId))
      .order("desc")
      .collect();
 
    // Trier les notes épinglées en haut
    return notes.sort((a, b) => {
      if (a.isPinned && !b.isPinned) return -1;
      if (!a.isPinned && b.isPinned) return 1;
      return 0;
    });
  },
});
 
// Rechercher dans les notes par contenu
export const search = query({
  args: {
    userId: v.string(),
    searchTerm: v.string(),
  },
  handler: async (ctx, args) => {
    const results = await ctx.db
      .query("notes")
      .withSearchIndex("search_notes", (q) =>
        q.search("content", args.searchTerm).eq("userId", args.userId)
      )
      .collect();
 
    return results;
  },
});

Remarquez comment les requêtes déclarent leurs args avec des validateurs. Convex les valide au runtime et infère les types TypeScript à la compilation.

Étape 5 : Écrire les fonctions de mutation

Les mutations sont la façon de modifier les données dans Convex. Comme les requêtes, elles sont validées, typées et transactionnelles — chaque mutation s'exécute dans une transaction sérialisable.

Ajoutez ces mutations à convex/notes.ts :

// Créer une nouvelle note
export const create = mutation({
  args: {
    userId: v.string(),
    title: v.string(),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    const noteId = await ctx.db.insert("notes", {
      userId: args.userId,
      title: args.title,
      content: args.content,
      isPinned: false,
    });
    return noteId;
  },
});
 
// Mettre à jour une note existante
export const update = mutation({
  args: {
    noteId: v.id("notes"),
    title: v.optional(v.string()),
    content: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const { noteId, ...updates } = args;
    const cleanUpdates: Record<string, string> = {};
    if (updates.title !== undefined) cleanUpdates.title = updates.title;
    if (updates.content !== undefined) cleanUpdates.content = updates.content;
 
    await ctx.db.patch(noteId, cleanUpdates);
  },
});
 
// Basculer le statut épinglé
export const togglePin = mutation({
  args: { noteId: v.id("notes") },
  handler: async (ctx, args) => {
    const note = await ctx.db.get(args.noteId);
    if (!note) throw new Error("Note not found");
    await ctx.db.patch(args.noteId, { isPinned: !note.isPinned });
  },
});
 
// Supprimer une note
export const remove = mutation({
  args: { noteId: v.id("notes") },
  handler: async (ctx, args) => {
    const note = await ctx.db.get(args.noteId);
    if (!note) throw new Error("Note not found");
 
    if (note.fileId) {
      await ctx.storage.delete(note.fileId);
    }
    await ctx.db.delete(args.noteId);
  },
});

Points importants :

  • ctx.db.insert() — crée un nouveau document et retourne son ID
  • ctx.db.patch() — met à jour partiellement un document (seuls les champs spécifiés)
  • ctx.db.delete() — supprime un document
  • ctx.storage.delete() — supprime un fichier du stockage Convex
  • Chaque mutation est atomique — si une étape échoue, toute la transaction est annulée

Étape 6 : Configurer le fournisseur Convex

Pour utiliser Convex dans vos composants React, vous devez envelopper votre application avec le ConvexProvider.

Créez src/components/ConvexClientProvider.tsx :

"use client";
 
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
 
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
 
export default function ConvexClientProvider({
  children,
}: {
  children: ReactNode;
}) {
  return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}

Puis enveloppez votre application dans src/app/layout.tsx :

import type { Metadata } from "next";
import "./globals.css";
import ConvexClientProvider from "@/components/ConvexClientProvider";
 
export const metadata: Metadata = {
  title: "Convex Notes",
  description: "Notes collaboratives en temps réel propulsées par Convex",
};
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="fr">
      <body>
        <ConvexClientProvider>{children}</ConvexClientProvider>
      </body>
    </html>
  );
}

Étape 7 : Construire l'interface des notes

Maintenant la partie excitante — construire les composants React qui consomment votre backend Convex. La magie de Convex est que useQuery s'abonne automatiquement aux mises à jour en temps réel. Quand un client crée, modifie ou supprime une note, tous les autres clients connectés voient le changement instantanément.

Créez src/app/page.tsx :

"use client";
 
import { useQuery, useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { useState } from "react";
import { Id } from "../../convex/_generated/dataModel";
 
const USER_ID = "demo-user";
 
export default function NotesApp() {
  const notes = useQuery(api.notes.list, { userId: USER_ID });
  const createNote = useMutation(api.notes.create);
  const updateNote = useMutation(api.notes.update);
  const togglePin = useMutation(api.notes.togglePin);
  const removeNote = useMutation(api.notes.remove);
 
  const [editingId, setEditingId] = useState<Id<"notes"> | null>(null);
  const [searchTerm, setSearchTerm] = useState("");
 
  const searchResults = useQuery(
    api.notes.search,
    searchTerm.length > 0 ? { userId: USER_ID, searchTerm } : "skip"
  );
 
  const displayNotes = searchTerm.length > 0 ? searchResults : notes;
 
  const handleCreate = async () => {
    await createNote({
      userId: USER_ID,
      title: "Note sans titre",
      content: "",
    });
  };
 
  if (notes === undefined) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
      </div>
    );
  }
 
  return (
    <main className="max-w-4xl mx-auto p-6">
      <header className="flex items-center justify-between mb-8">
        <h1 className="text-3xl font-bold">Notes Convex</h1>
        <button
          onClick={handleCreate}
          className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
        >
          + Nouvelle note
        </button>
      </header>
 
      <input
        type="text"
        placeholder="Rechercher dans les notes..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        className="w-full p-3 border rounded-lg mb-6 focus:outline-none focus:ring-2 focus:ring-blue-500"
      />
 
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {displayNotes?.map((note) => (
          <div
            key={note._id}
            className={`border rounded-lg p-4 hover:shadow-md transition ${
              note.isPinned ? "border-yellow-400 bg-yellow-50" : ""
            }`}
          >
            <div className="flex items-start justify-between mb-2">
              <h2 className="font-semibold text-lg">{note.title}</h2>
              <button
                onClick={() => togglePin({ noteId: note._id })}
                className="text-xl"
              >
                {note.isPinned ? "📌" : "📍"}
              </button>
            </div>
            <p className="text-gray-600 mb-4 line-clamp-3">
              {note.content || "Note vide..."}
            </p>
            <div className="flex gap-2">
              <button
                onClick={() => setEditingId(note._id)}
                className="text-sm text-blue-600 hover:underline"
              >
                Modifier
              </button>
              <button
                onClick={() => removeNote({ noteId: note._id })}
                className="text-sm text-red-600 hover:underline"
              >
                Supprimer
              </button>
            </div>
          </div>
        ))}
      </div>
 
      {displayNotes?.length === 0 && (
        <p className="text-center text-gray-400 mt-12">
          Aucune note pour le moment. Cliquez sur &quot;+ Nouvelle note&quot; pour commencer.
        </p>
      )}
    </main>
  );
}

L'idée clé est useQuery. Quand vous appelez useQuery(api.notes.list, ...), Convex :

  1. Exécute votre fonction de requête sur le serveur
  2. Envoie le résultat au client
  3. S'abonne à toutes les tables touchées par cette requête
  4. Réexécute automatiquement la requête et pousse les nouveaux résultats quand les données changent

C'est fondamentalement différent des API REST ou même des abonnements GraphQL — il n'y a pas d'invalidation manuelle du cache, pas de mises à jour optimistes à gérer, et pas de données périmées.

Étape 8 : Ajouter le téléchargement de fichiers

Convex dispose d'un stockage de fichiers intégré. Vous pouvez télécharger des fichiers directement depuis le client et les référencer dans vos documents.

Ajoutez une mutation de téléchargement à convex/notes.ts :

// Générer une URL de téléchargement pour le client
export const generateUploadUrl = mutation({
  handler: async (ctx) => {
    return await ctx.storage.generateUploadUrl();
  },
});
 
// Attacher un fichier à une note
export const attachFile = mutation({
  args: {
    noteId: v.id("notes"),
    fileId: v.id("_file_storage"),
    fileName: v.string(),
  },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.noteId, {
      fileId: args.fileId,
      fileName: args.fileName,
    });
  },
});

Créez un composant de téléchargement dans src/components/FileUpload.tsx :

"use client";
 
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Id } from "../../convex/_generated/dataModel";
import { useRef } from "react";
 
export default function FileUpload({ noteId }: { noteId: Id<"notes"> }) {
  const generateUploadUrl = useMutation(api.notes.generateUploadUrl);
  const attachFile = useMutation(api.notes.attachFile);
  const fileInputRef = useRef<HTMLInputElement>(null);
 
  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
 
    // Étape 1 : Obtenir une URL de téléchargement temporaire de Convex
    const uploadUrl = await generateUploadUrl();
 
    // Étape 2 : Envoyer le fichier à l'URL de téléchargement
    const response = await fetch(uploadUrl, {
      method: "POST",
      headers: { "Content-Type": file.type },
      body: file,
    });
 
    const { storageId } = await response.json();
 
    // Étape 3 : Sauvegarder la référence du fichier dans la note
    await attachFile({
      noteId,
      fileId: storageId,
      fileName: file.name,
    });
  };
 
  return (
    <div>
      <input
        type="file"
        ref={fileInputRef}
        onChange={handleUpload}
        className="hidden"
      />
      <button
        onClick={() => fileInputRef.current?.click()}
        className="text-sm text-gray-500 hover:text-gray-700"
      >
        📎 Joindre un fichier
      </button>
    </div>
  );
}

Étape 9 : Ajouter l'authentification avec Clerk

Pour les applications en production, vous devez isoler les données par utilisateur. Convex s'intègre parfaitement avec Clerk, Auth0 et d'autres fournisseurs d'authentification.

Installez les paquets Clerk :

npm install @clerk/nextjs

Inscrivez-vous pour un compte Clerk gratuit sur clerk.com, créez une application et ajoutez vos clés à .env.local :

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud

Mettez à jour votre fournisseur Convex pour l'intégrer avec Clerk. Remplacez src/components/ConvexClientProvider.tsx :

"use client";
 
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { ConvexReactClient } from "convex/react";
import { ClerkProvider, useAuth } from "@clerk/nextjs";
import { ReactNode } from "react";
 
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
 
export default function ConvexClientProvider({
  children,
}: {
  children: ReactNode;
}) {
  return (
    <ClerkProvider>
      <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
        {children}
      </ConvexProviderWithClerk>
    </ClerkProvider>
  );
}

Créez ensuite convex/auth.config.ts :

export default {
  providers: [
    {
      domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
      applicationID: "convex",
    },
  ],
};

Maintenant mettez à jour vos requêtes pour utiliser les utilisateurs authentifiés :

export const list = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return [];
 
    const userId = identity.subject;
    const notes = await ctx.db
      .query("notes")
      .withIndex("by_user", (q) => q.eq("userId", userId))
      .order("desc")
      .collect();
 
    return notes.sort((a, b) => {
      if (a.isPinned && !b.isPinned) return -1;
      if (!a.isPinned && b.isPinned) return 1;
      return 0;
    });
  },
});

Avec ctx.auth.getUserIdentity(), Convex vérifie automatiquement le token JWT de Clerk. Chaque utilisateur ne voit que ses propres notes.

Étape 10 : Mises à jour optimistes

Bien que Convex soit déjà rapide (les mises à jour arrivent en 20-50 ms), vous pouvez rendre l'interface encore plus réactive avec des mises à jour optimistes :

const createNote = useMutation(api.notes.create).withOptimisticUpdate(
  (localStore, args) => {
    const existingNotes = localStore.getQuery(api.notes.list, {});
    if (existingNotes === undefined) return;
 
    localStore.setQuery(api.notes.list, {}, [
      {
        _id: crypto.randomUUID() as any,
        _creationTime: Date.now(),
        userId: args.userId,
        title: args.title,
        content: args.content,
        isPinned: false,
      },
      ...existingNotes,
    ]);
  }
);

Les mises à jour optimistes fonctionnent en modifiant le cache de requêtes local. Quand le serveur confirme la mutation, Convex remplace le résultat optimiste par le vrai. Si la mutation échoue, la mise à jour optimiste est automatiquement annulée.

Étape 11 : Déployer en production

Convex sépare les déploiements de développement et de production :

# Déployer votre backend en production
npx convex deploy
 
# Construire et déployer votre frontend Next.js
npm run build

Votre backend Convex fonctionne maintenant sur l'infrastructure managée de Convex — distribuée globalement, avec auto-scaling et sauvegardes automatiques. Il n'y a pas de serveur à gérer, pas de base de données à configurer.

Tester votre implémentation

  1. Synchronisation en temps réel : Ouvrez l'application dans deux fenêtres côte à côte. Créez une note dans l'une — elle doit apparaître instantanément dans l'autre
  2. Recherche : Tapez dans la barre de recherche et vérifiez que les résultats se filtrent en temps réel
  3. Épingler/désépingler : Épinglez une note et vérifiez qu'elle se déplace en haut de la liste
  4. Téléchargement de fichiers : Attachez un fichier et vérifiez qu'il persiste après un rechargement de page
  5. Authentification : Déconnectez-vous et vérifiez que les notes ne sont pas accessibles

Comparaison de Convex avec les backends traditionnels

FonctionnalitéStack traditionnelConvex
Mises à jour temps réelConfiguration WebSocket manuelleAutomatique avec useQuery
Sécurité de typesTypes ORM + types API séparésDe bout en bout du schéma au client
TransactionsGestion manuelle des transactionsChaque mutation est transactionnelle
CacheRedis, invalidation de requêtesCache réactif automatique
Stockage de fichiersS3 + URLs signéesctx.storage intégré
DéploiementDocker, Kubernetes, etc.npx convex deploy
Mise à l'échelleScaling horizontal manuelAutomatique

Dépannage

"Could not find module convex/_generated" : Exécutez npx convex dev pour générer les types. Le répertoire _generated est créé automatiquement au premier lancement du serveur de développement.

Les requêtes retournent undefined : useQuery retourne undefined pendant le chargement. Gérez toujours l'état de chargement avant d'accéder aux données.

Erreurs "Not authenticated" : Assurez-vous que le domaine émetteur JWT de Clerk est correctement configuré dans les variables d'environnement Convex.

Prochaines étapes

  • Ajouter l'édition de texte riche avec Tiptap ou Slate pour une expérience plus proche de Notion
  • Implémenter le partage — permettre aux utilisateurs de partager des notes via des liens uniques
  • Ajouter des fonctions Convex planifiées pour des fonctionnalités comme les rappels ou l'archivage automatique
  • Explorer les actions Convex pour appeler des API externes (notifications par email, résumé par IA)

Conclusion

Vous avez construit une application full-stack complètement réactive et en temps réel avec Convex et Next.js 15. Les points clés à retenir :

  • Convex élimine le code de liaison — pas d'endpoints REST, pas de configuration ORM, pas de configuration WebSocket
  • Le temps réel est le comportement par défaut — chaque useQuery s'abonne automatiquement aux mises à jour en direct
  • TypeScript de bout en bout — les validateurs de schéma génèrent des types qui circulent de la base de données à l'interface
  • Les transactions sont automatiques — chaque mutation est sérialisable, éliminant les conditions de concurrence
  • Le stockage de fichiers est intégré — pas besoin de bucket S3 séparé ou de configuration CDN

Convex représente un changement dans la façon dont nous concevons les backends : au lieu de construire une infrastructure pour déplacer les données, vous écrivez des fonctions TypeScript qui lisent et écrivent des données, et Convex gère tout le reste — synchronisation en temps réel, cache, mise à l'échelle et déploiement.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Configuration Vibe Coding : Configurer votre Environnement de Developpement Assiste par IA.

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