TipTap v3 avec Next.js 15 : Construire un éditeur de texte riche pour la production

TipTap v3 est un éditeur de texte riche sans interface par défaut, construit au-dessus de ProseMirror. Contrairement aux éditeurs classiques livrés avec une UI lourde, TipTap vous laisse le contrôle complet du balisage, des commandes et du style. C'est pourquoi il est devenu l'éditeur de référence pour les produits SaaS modernes comme Linear, Gitbook, les applications de type Notion et les systèmes de gestion de contenu.
Dans ce tutoriel, vous allez construire un éditeur de texte riche complet avec Next.js 15 et TipTap v3. À la fin, vous disposerez d'un éditeur prêt pour la production avec contrôles de mise en forme, téléversement d'images, tableaux et persistance du contenu en base de données sous forme de JSON structuré.
Prérequis
Avant de commencer, assurez-vous de disposer de :
- Node.js 20 ou plus récent installé
- Une connaissance de base du Next.js App Router
- Une bonne maîtrise de React et TypeScript
- Un éditeur de code tel que VS Code
- Un gestionnaire de paquets : npm, pnpm ou bun
Ce que vous allez construire
Vous allez créer un éditeur qui prend en charge :
- Mise en forme du texte : gras, italique, souligné, barré, code
- Titres (H1 à H3), listes à puces et numérotées, citations
- Liens avec éditeur popover
- Images en ligne avec système de téléversement
- Tableaux avec colonnes redimensionnables
- Une barre d'outils collante stylée avec Tailwind CSS
- Persistance du contenu au format JSON via une API
- Rendu du contenu sauvegardé en HTML sur une page en lecture seule
Étape 1 : Créer un nouveau projet Next.js 15
Commencez par générer un nouveau projet Next.js avec App Router et TypeScript.
npx create-next-app@latest tiptap-editor --typescript --tailwind --app --src-dir=false --import-alias "@/*"
cd tiptap-editorRépondez ainsi aux questions :
- TypeScript : Oui
- ESLint : Oui
- Tailwind CSS : Oui
- App Router : Oui
- Turbopack : Oui
Étape 2 : Installer TipTap v3 et les extensions
Installez les paquets TipTap de base avec le starter kit et les extensions que nous utiliserons.
npm install @tiptap/react @tiptap/starter-kit @tiptap/pm
npm install @tiptap/extension-link @tiptap/extension-image
npm install @tiptap/extension-placeholder @tiptap/extension-table
npm install @tiptap/extension-table-row @tiptap/extension-table-cell @tiptap/extension-table-headerLe paquet @tiptap/starter-kit regroupe les extensions les plus courantes comme les paragraphes, le gras, l'italique, les titres, les listes et l'historique. Les autres extensions sont installées séparément pour n'embarquer que le strict nécessaire et garder un bundle léger.
Étape 3 : Créer le composant éditeur
Créez le fichier components/editor/Editor.tsx qui encapsule TipTap et expose une interface React propre.
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import Toolbar from "./Toolbar";
type EditorProps = {
content?: string;
onChange?: (html: string, json: unknown) => void;
editable?: boolean;
};
export default function Editor({ content = "", onChange, editable = true }: EditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: { class: "text-blue-600 underline" },
}),
Image.configure({ inline: false, allowBase64: false }),
Placeholder.configure({
placeholder: "Commencez à écrire votre histoire...",
}),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
content,
editable,
immediatelyRender: false,
editorProps: {
attributes: {
class: "prose prose-slate max-w-none focus:outline-none min-h-[320px] p-4",
},
},
onUpdate: ({ editor }) => {
onChange?.(editor.getHTML(), editor.getJSON());
},
});
if (!editor) return null;
return (
<div className="border rounded-lg bg-white shadow-sm">
{editable && <Toolbar editor={editor} />}
<EditorContent editor={editor} />
</div>
);
}Deux points importants :
- L'option
immediatelyRender: falseest indispensable avec Next.js 15 pour éviter les erreurs d'hydratation, car TipTap rend côté client. - La classe
prosede Tailwind Typography fournit de superbes styles par défaut pour les titres, listes et citations sans configuration supplémentaire.
Étape 4 : Installer Tailwind Typography
TipTap n'est livré avec aucun style. Tailwind Typography offre une expérience de lecture agréable dès le départ.
npm install -D @tailwindcss/typographyAjoutez le plugin à votre configuration Tailwind. Avec Tailwind v4 et Next.js 15, insérez cette directive dans app/globals.css :
@import "tailwindcss";
@plugin "@tailwindcss/typography";Étape 5 : Construire la barre d'outils
Créez components/editor/Toolbar.tsx avec les boutons de mise en forme. Chaque bouton utilise editor.chain() pour appliquer les commandes.
"use client";
import type { Editor } from "@tiptap/react";
import {
Bold, Italic, Strikethrough, Code, Heading1, Heading2, Heading3,
List, ListOrdered, Quote, Undo, Redo, Link as LinkIcon, Image as ImgIcon,
Table as TableIcon,
} from "lucide-react";
type Props = { editor: Editor };
function ToolbarButton({
onClick,
active,
disabled,
children,
title,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
children: React.ReactNode;
title: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={`p-2 rounded hover:bg-slate-100 transition ${
active ? "bg-slate-200 text-slate-900" : "text-slate-600"
} disabled:opacity-40`}
>
{children}
</button>
);
}
export default function Toolbar({ editor }: Props) {
const addLink = () => {
const url = window.prompt("Entrez l'URL");
if (url === null) return;
if (url === "") {
editor.chain().focus().unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
};
const addTable = () => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
};
return (
<div className="flex flex-wrap gap-1 p-2 border-b bg-slate-50 sticky top-0 z-10">
<ToolbarButton title="Gras" onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")}>
<Bold size={16} />
</ToolbarButton>
<ToolbarButton title="Italique" onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")}>
<Italic size={16} />
</ToolbarButton>
<ToolbarButton title="Barré" onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")}>
<Strikethrough size={16} />
</ToolbarButton>
<ToolbarButton title="Code" onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive("code")}>
<Code size={16} />
</ToolbarButton>
<span className="w-px bg-slate-300 mx-1" />
<ToolbarButton title="H1" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive("heading", { level: 1 })}>
<Heading1 size={16} />
</ToolbarButton>
<ToolbarButton title="H2" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive("heading", { level: 2 })}>
<Heading2 size={16} />
</ToolbarButton>
<ToolbarButton title="H3" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive("heading", { level: 3 })}>
<Heading3 size={16} />
</ToolbarButton>
<span className="w-px bg-slate-300 mx-1" />
<ToolbarButton title="Liste à puces" onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")}>
<List size={16} />
</ToolbarButton>
<ToolbarButton title="Liste ordonnée" onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")}>
<ListOrdered size={16} />
</ToolbarButton>
<ToolbarButton title="Citation" onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive("blockquote")}>
<Quote size={16} />
</ToolbarButton>
<span className="w-px bg-slate-300 mx-1" />
<ToolbarButton title="Lien" onClick={addLink} active={editor.isActive("link")}>
<LinkIcon size={16} />
</ToolbarButton>
<ToolbarButton title="Tableau" onClick={addTable}>
<TableIcon size={16} />
</ToolbarButton>
<span className="w-px bg-slate-300 mx-1" />
<ToolbarButton title="Annuler" onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()}>
<Undo size={16} />
</ToolbarButton>
<ToolbarButton title="Rétablir" onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()}>
<Redo size={16} />
</ToolbarButton>
</div>
);
}Installez Lucide pour les icônes si ce n'est pas déjà fait.
npm install lucide-reactÉtape 6 : Ajouter une page qui utilise l'éditeur
Créez app/editor/page.tsx pour héberger l'éditeur et capturer ses sorties.
"use client";
import { useState } from "react";
import Editor from "@/components/editor/Editor";
export default function EditorPage() {
const [html, setHtml] = useState("");
const [json, setJson] = useState<unknown>(null);
const [saving, setSaving] = useState(false);
const save = async () => {
setSaving(true);
const res = await fetch("/api/documents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html, json }),
});
setSaving(false);
if (!res.ok) alert("Échec de l'enregistrement");
};
return (
<main className="max-w-3xl mx-auto p-8 space-y-4">
<h1 className="text-3xl font-bold">Nouveau document</h1>
<Editor onChange={(h, j) => { setHtml(h); setJson(j); }} />
<button
onClick={save}
disabled={saving}
className="px-4 py-2 bg-slate-900 text-white rounded hover:bg-slate-800 disabled:opacity-50"
>
{saving ? "Enregistrement..." : "Enregistrer"}
</button>
</main>
);
}Étape 7 : Créer un endpoint d'enregistrement
Stockez les documents au format JSON. Utiliser JSON plutôt que HTML protège vos données à long terme car vous pourrez les rerendre plus tard avec une configuration d'éditeur différente.
Créez app/api/documents/route.ts.
import { NextResponse } from "next/server";
type Payload = { html: string; json: unknown };
export async function POST(req: Request) {
const body = (await req.json()) as Payload;
if (!body.json) {
return NextResponse.json({ error: "Missing json" }, { status: 400 });
}
// Remplacez par votre base de données (Drizzle, Prisma, etc.).
// await db.insert(documents).values({ html: body.html, json: body.json });
return NextResponse.json({ ok: true });
}En production, persistez json dans une colonne JSONB de Postgres. Le champ html reste utile pour l'indexation de recherche ou le rendu rapide mais doit être traité comme un cache dérivé.
Étape 8 : Implémenter le téléversement d'images
Plutôt que de demander une URL brute, laissez les utilisateurs téléverser des images. Ajoutez d'abord une route de téléversement dans app/api/uploads/route.ts.
import { NextResponse } from "next/server";
import { writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import path from "node:path";
export async function POST(req: Request) {
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file" }, { status: 400 });
const buffer = Buffer.from(await file.arrayBuffer());
const ext = path.extname(file.name) || ".png";
const name = `${randomUUID()}${ext}`;
const dest = path.join(process.cwd(), "public", "uploads", name);
await writeFile(dest, buffer);
return NextResponse.json({ url: `/uploads/${name}` });
}En production, remplacez l'écriture locale par un client S3 ou Vercel Blob. L'approche locale ne convient qu'au développement.
Mettez ensuite à jour le bouton image de la barre d'outils pour qu'il téléverse un fichier au lieu de demander une URL.
const uploadImage = async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body: form });
const { url } = await res.json();
editor.chain().focus().setImage({ src: url, alt: file.name }).run();
};
input.click();
};Remplacez le précédent gestionnaire addImage par ce flux. TipTap insérera l'image dès que le serveur aura répondu.
Étape 9 : Afficher le contenu sauvegardé
Créez une vue en lecture seule dans app/documents/[id]/page.tsx qui rerend le JSON stocké en HTML avec les mêmes extensions.
import { generateHTML } from "@tiptap/html";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
type Params = { params: Promise<{ id: string }> };
export default async function DocumentPage({ params }: Params) {
const { id } = await params;
const doc = await loadDocumentById(id); // à implémenter selon votre base
const html = generateHTML(doc.json, [
StarterKit,
Link,
Image,
Table,
TableRow,
TableHeader,
TableCell,
]);
return (
<article
className="prose prose-slate max-w-3xl mx-auto p-8"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}Comme generateHTML s'exécute au moment de la requête à l'intérieur d'un React Server Component, le client n'a pas besoin de télécharger ni de monter TipTap pour simplement afficher le contenu. Vos pages de lecture restent rapides.
Étape 10 : Ajouter l'édition collaborative (optionnel)
TipTap fournit une intégration native avec Yjs pour la collaboration en temps réel. Installez les paquets nécessaires.
npm install @tiptap/extension-collaboration yjs y-websocketRemplacez l'historique du StarterKit par celui de Yjs et connectez un provider.
import Collaboration from "@tiptap/extension-collaboration";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
const ydoc = new Y.Doc();
const provider = new WebsocketProvider("wss://your-ws-server", "room-1", ydoc);
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: ydoc }),
],
});En production, hébergez un serveur Yjs comme Hocuspocus ou utilisez TipTap Cloud.
Étape 11 : Sécuriser la sortie HTML
Si vous affichez directement du HTML stocké avec dangerouslySetInnerHTML, pensez à le nettoyer côté serveur. Un attaquant peut injecter des scripts via un copier-coller. Utilisez une bibliothèque comme isomorphic-dompurify avant de faire confiance à tout HTML.
import DOMPurify from "isomorphic-dompurify";
const safe = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });L'alternative la plus sûre consiste à persister et rerender la représentation JSON : par construction elle ne peut pas contenir de scripts, car TipTap n'émet que des types de nœuds connus.
Étape 12 : Optimisation du bundle
TipTap fournit des modules tree-shakables, mais l'ensemble complet des extensions ajoute environ 120 ko compressés au bundle client. Réduisez cela de deux façons :
- Importez dynamiquement le composant éditeur avec
next/dynamicetssr: false - N'installez que les extensions réellement utilisées plutôt que tout le starter kit
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@/components/editor/Editor"), {
ssr: false,
loading: () => <div className="h-80 animate-pulse bg-slate-100 rounded-lg" />,
});Tester votre implémentation
Lancez le serveur de développement et ouvrez /editor dans le navigateur.
npm run devVérifiez les comportements suivants :
- La saisie crée des paragraphes et le placeholder disparaît
- Les boutons gras, italique et code basculent la mise en forme du texte sélectionné
- Les boutons de titre changent le type du bloc courant
- Les listes et citations encadrent le paragraphe actif
- Le bouton lien ouvre une invite et produit un lien souligné cliquable
- Le téléversement d'image insère une image dans le document après la réponse du serveur
- L'insertion d'un tableau crée une grille 3x3 avec colonnes redimensionnables
- Cliquer sur Enregistrer envoie un POST avec
htmletjson - Recharger le document sauvegardé affiche le même contenu sans l'habillage de l'éditeur
Dépannage
Avertissements d'hydratation au montage. Définissez immediatelyRender: false lors de l'appel à useEditor. Cela empêche TipTap de se rendre pendant le SSR.
L'erreur document is not defined au build. Ajoutez toujours "use client" en haut des fichiers de l'éditeur et importez dynamiquement le composant éditeur dans les pages rendues côté serveur.
Les tableaux ne s'affichent pas. Assurez-vous d'enregistrer les quatre extensions de tableau (Table, TableRow, TableHeader, TableCell) à la fois dans l'éditeur client et dans generateHTML côté serveur.
Les images collées deviennent d'énormes chaînes base64. Configurez l'extension Image avec allowBase64: false et interceptez les événements de collage pour téléverser le fichier d'abord.
Prochaines étapes
Maintenant que vous avez un éditeur fonctionnel, envisagez de l'étendre davantage.
- Ajoutez des commandes slash qui ouvrent un menu quand l'utilisateur tape
/, à la manière de Notion - Implémentez les mentions avec
@tiptap/extension-mention - Intégrez la complétion IA via le Vercel AI SDK aux côtés de l'éditeur, abordée dans notre tutoriel d'agent IA avec Vercel AI SDK
- Prenez en charge les fichiers joints en étendant l'extension Image en un nœud File générique
- Persistez les brouillons automatiquement via un hook d'autosave avec debounce
Conclusion
TipTap v3 offre un éditeur professionnel et extensible qui s'intègre naturellement à Next.js 15. Vous disposez désormais d'un flux complet d'écriture et de rendu avec mise en forme, médias et stockage JSON structuré. Comme le document sous-jacent n'est pas du HTML brut, vous pouvez faire évoluer son schéma au fil du temps sans perdre la compatibilité avec les anciennes données.
Pour des personnalisations plus poussées, explorez la création de nœuds personnalisés via l'API Node.create(), qui vous permet d'ajouter des embeds interactifs, des diagrammes ou des types de contenu spécifiques à votre métier. Le texte riche n'est plus un composant banal — avec TipTap il devient une surface produit essentielle que vous contrôlez entièrement.
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 Full-Stack avec Appwrite Cloud et Next.js 15
Apprenez à construire une application full-stack complète en utilisant Appwrite Cloud comme backend-as-a-service et Next.js 15 App Router. Ce tutoriel couvre l'authentification, les bases de données, le stockage de fichiers et les fonctionnalités temps réel.

Construire une application full-stack en temps réel avec Convex et Next.js 15
Apprenez à construire une application full-stack en temps réel avec Convex et Next.js 15. Ce tutoriel couvre la conception de schémas, les requêtes, les mutations, les abonnements en temps réel, l'authentification et le téléchargement de fichiers — le tout avec une sécurité de types de bout en bout.

Construire une application complète avec Firebase et Next.js 15 : Auth, Firestore et temps réel
Apprenez à créer une application full-stack avec Next.js 15 et Firebase. Ce guide couvre l'authentification, Firestore, les mises à jour en temps réel, les Server Actions et le déploiement sur Vercel.