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

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-notesAcceptez 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 initLa 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
v—v.string(),v.boolean(),v.optional()etv.id()définissent les types de champs avec validation au runtime - Index —
by_userpermet des requêtes efficaces filtrées paruserId. Convex exige de déclarer les index à l'avance - Index de recherche —
search_notespermet la recherche textuelle complète sur le champcontent _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 devGardez 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 IDctx.db.patch()— met à jour partiellement un document (seuls les champs spécifiés)ctx.db.delete()— supprime un documentctx.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 "+ Nouvelle note" pour commencer.
</p>
)}
</main>
);
}L'idée clé est useQuery. Quand vous appelez useQuery(api.notes.list, ...), Convex :
- Exécute votre fonction de requête sur le serveur
- Envoie le résultat au client
- S'abonne à toutes les tables touchées par cette requête
- 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/nextjsInscrivez-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.cloudMettez à 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 buildVotre 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
- 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
- Recherche : Tapez dans la barre de recherche et vérifiez que les résultats se filtrent en temps réel
- Épingler/désépingler : Épinglez une note et vérifiez qu'elle se déplace en haut de la liste
- Téléchargement de fichiers : Attachez un fichier et vérifiez qu'il persiste après un rechargement de page
- Authentification : Déconnectez-vous et vérifiez que les notes ne sont pas accessibles
Comparaison de Convex avec les backends traditionnels
| Fonctionnalité | Stack traditionnel | Convex |
|---|---|---|
| Mises à jour temps réel | Configuration WebSocket manuelle | Automatique avec useQuery |
| Sécurité de types | Types ORM + types API séparés | De bout en bout du schéma au client |
| Transactions | Gestion manuelle des transactions | Chaque mutation est transactionnelle |
| Cache | Redis, invalidation de requêtes | Cache réactif automatique |
| Stockage de fichiers | S3 + URLs signées | ctx.storage intégré |
| Déploiement | Docker, Kubernetes, etc. | npx convex deploy |
| Mise à l'échelle | Scaling horizontal manuel | Automatique |
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
useQuerys'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.
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

Construire une application temps réel avec Supabase et Next.js 15 : guide complet
Apprenez à construire une application full-stack en temps réel avec Supabase et Next.js 15 App Router. Ce guide couvre l'authentification, la base de données, Row Level Security et les abonnements temps réel.

Construire une Application Full-Stack avec Drizzle ORM et Next.js 15 : Base de Donnees Type-Safe du Zero a la Production
Apprenez a construire une application full-stack type-safe avec Drizzle ORM et Next.js 15. Ce tutoriel pratique couvre la conception de schemas, les migrations, les Server Actions, les operations CRUD et le deploiement avec PostgreSQL.

Authentifier votre application Next.js 15 avec Auth.js v5 : Email, OAuth et contrôle des rôles
Apprenez à ajouter une authentification prête pour la production à votre application Next.js 15 avec Auth.js v5. Ce guide complet couvre Google OAuth, les identifiants email/mot de passe, les routes protégées, le middleware et le contrôle d'accès basé sur les rôles.