Tout produit IA finit par avoir besoin d'une interface de chat, et en construire une aussi soignée que ChatGPT ou Claude.ai est bien plus difficile qu'il n'y paraît. Du markdown en streaming sans scintillement, un défilement automatique qui s'arrête quand l'utilisateur remonte, l'édition et la régénération de messages, la navigation entre branches, la visualisation des appels d'outils, les pièces jointes — chacun de ces éléments est un mini-projet à part entière.
assistant-ui résout ce problème. C'est la bibliothèque React open source la plus populaire pour le chat IA, utilisée aussi bien par des startups que par de grandes entreprises. Au lieu de livrer un widget monolithique opaque, elle suit la philosophie de shadcn/ui : des primitives composables qui vivent dans votre code, stylées avec Tailwind CSS, que vous possédez et personnalisez entièrement.
Dans ce tutoriel, vous allez construire une application de chat IA complète et prête pour la production avec Next.js 15, assistant-ui et le Vercel AI SDK, propulsée par Claude d'Anthropic. À la fin, vous aurez un chat en streaming, un rendu d'outils personnalisé, de la generative UI et une voie claire vers la production.
Ce que vous allez construire
Une application de chat IA complète avec :
- Réponses en streaming temps réel avec rendu markdown
- Édition de messages, régénération et navigation entre branches (comme le flux d'édition de ChatGPT)
- Un outil météo avec une carte generative UI personnalisée rendue dans la conversation
- Un viewport à défilement automatique qui respecte la position de défilement de l'utilisateur
- Raccourcis clavier, états de chargement et accessibilité intégrés
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé
- Une bonne connaissance de React et du Next.js App Router
- Des bases en TypeScript
- Une clé API Anthropic (ou tout fournisseur compatible avec l'AI SDK)
Pourquoi assistant-ui plutôt que tout construire soi-même ?
assistant-ui gère plus de 50 détails UX que vous devriez sinon implémenter manuellement : défilement automatique intelligent, animation du texte en streaming, mises à jour optimistes, gestion de l'annulation, branchement des messages et accessibilité ARIA complète.
Une comparaison rapide des principales approches en 2026 :
| Approche | Propriété du code | Effort | Flexibilité |
|---|---|---|---|
| Tout construire soi-même | Totale | Très élevé | Totale |
| Widgets de chat embarqués | Aucune | Faible | Faible |
| Vercel AI Elements | Totale | Moyen | Élevée |
| assistant-ui | Totale | Faible | Élevée |
L'atout majeur d'assistant-ui est son architecture en primitives. Comme Radix UI l'a fait pour les composants génériques, elle fournit des primitives composables sans style (ThreadPrimitive, MessagePrimitive, ComposerPrimitive) plus une couche de départ stylée que vous générez dans votre projet et modifiez librement.
Étape 1 : Mise en place du projet
Créez un nouveau projet Next.js 15 :
npx create-next-app@latest assistant-chat --typescript --tailwind --eslint --app
cd assistant-chatInitialisez maintenant assistant-ui. La CLI génère les composants de chat directement dans votre code, à la manière de shadcn :
npx assistant-ui@latest initCette commande :
- Installe
@assistant-ui/react,@assistant-ui/react-ai-sdket@assistant-ui/react-markdown - Crée des composants stylés sous
components/assistant-ui/(thread, composer, texte markdown, et plus) - Ajoute la configuration Tailwind et les variables CSS nécessaires
Installez l'AI SDK et le fournisseur Anthropic pour le backend :
npm install ai @ai-sdk/anthropicAjoutez votre clé API dans .env.local :
ANTHROPIC_API_KEY=sk-ant-...Ne commitez jamais .env.local dans git. La clé API ne doit exister que côté serveur — l'architecture de runtime d'assistant-ui garantit que tous les appels au modèle passent par votre route backend, jamais depuis le navigateur.
Étape 2 : Créer la route API de chat
assistant-ui fonctionne avec n'importe quel backend grâce à son système de runtimes. Le choix le plus courant est le runtime AI SDK, qui se connecte à un route handler AI SDK standard.
Créez app/api/chat/route.ts :
import { anthropic } from "@ai-sdk/anthropic";
import { streamText, convertToModelMessages, type UIMessage } from "ai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-5"),
system:
"Tu es un assistant utile. Sois concis et utilise le format markdown.",
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}Trois points à noter :
streamTextlance l'appel au modèle et retourne immédiatement un résultat streamableconvertToModelMessagestransforme le format de messages UI (qui inclut les appels d'outils et les pièces jointes) vers le format du fournisseurtoUIMessageStreamResponseémet le flux SSE que le runtime d'assistant-ui consomme nativement
Étape 3 : Brancher le Runtime Provider
Le runtime est le cerveau d'assistant-ui — il gère l'état des messages, le streaming, le branchement et la communication avec votre API. Enveloppez votre application avec le provider.
Créez app/assistant.tsx :
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { Thread } from "@/components/assistant-ui/thread";
export function Assistant() {
const runtime = useChatRuntime({
api: "/api/chat",
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<main className="h-dvh">
<Thread />
</main>
</AssistantRuntimeProvider>
);
}Puis affichez-le depuis votre page. Remplacez app/page.tsx :
import { Assistant } from "./assistant";
export default function Home() {
return <Assistant />;
}Lancez le serveur de développement :
npm run devOuvrez http://localhost:3000 et vous avez déjà un chat fonctionnel : réponses en streaming, rendu markdown, blocs de code avec coloration syntaxique, boutons de copie, régénération de messages et défilement automatique intelligent. C'est toute la mise en place de base — deux fichiers et une commande CLI.
Étape 4 : Comprendre l'architecture en primitives
Ouvrez components/assistant-ui/thread.tsx. Tout ce que vous voyez à l'écran est composé de primitives que vous possédez désormais. Une vue simplifiée de la structure :
<ThreadPrimitive.Root>
<ThreadPrimitive.Viewport>
<ThreadPrimitive.Messages
components={{
UserMessage,
AssistantMessage,
EditComposer,
}}
/>
</ThreadPrimitive.Viewport>
<Composer />
</ThreadPrimitive.Root>Chaque primitive gère le comportement, pas l'apparence :
ThreadPrimitive.Viewportimplémente le défilement automatique intelligent — il suit le flux mais s'arrête dès que l'utilisateur remonte, puis propose un bouton « revenir en bas »ThreadPrimitive.Messagesvirtualise et affiche la liste des messages, en associant les rôles à vos composantsMessagePrimitive.Partsaffiche les parties d'un message — texte, raisonnement, appels d'outils — chacune avec son propre composantComposerPrimitivegère la saisie : envoi avec Entrée, nouvelle ligne avec Maj+Entrée, états désactivés pendant le streaming, annulation avec Échap
Comme ce sont vos fichiers, la personnalisation est directe. Vous voulez changer l'avatar de l'assistant, réorganiser les boutons d'action ou ajouter un pied de page à chaque message ? Modifiez le composant — pas d'API de configuration à contourner, pas de surcharges CSS sur des classes étrangères.
Personnaliser l'écran d'accueil
Trouvez le composant ThreadWelcome dans thread.tsx et adaptez-le :
const ThreadWelcome = () => {
return (
<div className="flex flex-col items-center justify-center py-16">
<h1 className="text-2xl font-semibold">Assistant Noqta</h1>
<p className="text-muted-foreground mt-2">
Posez-moi vos questions sur vos projets.
</p>
<div className="mt-6 flex gap-2">
<ThreadPrimitive.Suggestion
prompt="En quoi peux-tu m'aider ?"
method="replace"
autoSend
className="rounded-full border px-4 py-2 text-sm hover:bg-muted"
>
En quoi peux-tu m'aider ?
</ThreadPrimitive.Suggestion>
</div>
</div>
);
};ThreadPrimitive.Suggestion affiche une puce de prompt cliquable qui remplit (ou envoie) le composer — le même motif que ChatGPT utilise pour ses suggestions de départ.
Étape 5 : Ajouter un outil avec generative UI personnalisée
C'est là qu'assistant-ui brille. Les outils permettent au modèle d'appeler des fonctions dans votre backend ; les interfaces d'outils vous permettent d'afficher ces appels comme des composants riches et interactifs dans la conversation, au lieu de JSON brut.
D'abord, définissez l'outil côté serveur. Mettez à jour app/api/chat/route.ts :
import { anthropic } from "@ai-sdk/anthropic";
import {
streamText,
convertToModelMessages,
tool,
type UIMessage,
} from "ai";
import { z } from "zod";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-5"),
system: "Tu es un assistant utile avec accès aux données météo.",
messages: convertToModelMessages(messages),
tools: {
getWeather: tool({
description: "Obtenir la météo actuelle pour une ville",
inputSchema: z.object({
city: z.string().describe("Le nom de la ville"),
}),
execute: async ({ city }) => {
// En production, appelez ici une vraie API météo
const conditions = ["ensoleillé", "nuageux", "pluvieux", "venteux"];
return {
city,
temperature: Math.round(18 + Math.random() * 15),
condition:
conditions[Math.floor(Math.random() * conditions.length)],
humidity: Math.round(40 + Math.random() * 40),
};
},
}),
},
});
return result.toUIMessageStreamResponse();
}Créez maintenant l'interface personnalisée pour cet outil. Créez components/assistant-ui/weather-tool.tsx :
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
type WeatherArgs = {
city: string;
};
type WeatherResult = {
city: string;
temperature: number;
condition: string;
humidity: number;
};
export const WeatherToolUI = makeAssistantToolUI<WeatherArgs, WeatherResult>({
toolName: "getWeather",
render: ({ args, status, result }) => {
if (status.type === "running") {
return (
<div className="my-2 animate-pulse rounded-xl border bg-muted/50 p-4">
Vérification de la météo pour {args.city}...
</div>
);
}
if (status.type === "incomplete" || !result) {
return (
<div className="my-2 rounded-xl border border-destructive p-4">
Impossible de récupérer les données météo.
</div>
);
}
return (
<div className="my-2 rounded-xl border bg-gradient-to-br from-sky-50 to-blue-100 p-4 dark:from-sky-950 dark:to-blue-900">
<div className="text-sm text-muted-foreground">{result.city}</div>
<div className="text-3xl font-bold">{result.temperature}°C</div>
<div className="mt-1 flex gap-4 text-sm">
<span className="capitalize">{result.condition}</span>
<span>Humidité : {result.humidity}%</span>
</div>
</div>
);
},
});La fonction render reçoit le cycle de vie complet de l'appel d'outil :
status.type === "running"— le modèle a émis l'appel etexecuteest en cours ; lesargsarrivent progressivement en streaming, vous pouvez donc les afficher immédiatementstatus.type === "complete"—resultcontient ce queexecutea retournéstatus.type === "incomplete"— l'appel a été annulé ou a échoué
Enregistrez l'interface d'outil en l'affichant n'importe où dans le provider. Mettez à jour app/assistant.tsx :
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { Thread } from "@/components/assistant-ui/thread";
import { WeatherToolUI } from "@/components/assistant-ui/weather-tool";
export function Assistant() {
const runtime = useChatRuntime({
api: "/api/chat",
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<WeatherToolUI />
<main className="h-dvh">
<Thread />
</main>
</AssistantRuntimeProvider>
);
}Demandez au chat « Quel temps fait-il à Tunis ? » et observez le modèle appeler l'outil, la carte de chargement apparaître, puis la carte météo en dégradé s'afficher dans la conversation — une véritable expérience de generative UI en environ 60 lignes de code.
Les interfaces d'outils sont enregistrées par la présence du composant, pas par configuration. Vous pouvez donc les charger à la demande, les enregistrer conditionnellement par page, ou les livrer depuis des modules de fonctionnalités séparés — le registre se met à jour automatiquement au montage et au démontage.
Étape 6 : Branchement et édition de messages
assistant-ui intègre le branchement façon ChatGPT sans travail supplémentaire. Survolez n'importe quel message utilisateur et cliquez sur l'icône d'édition : soumettre la modification crée une nouvelle branche, et un sélecteur de branches apparaît, affichant par exemple « 2 / 2 » avec des flèches pour naviguer entre les alternatives.
Il en va de même pour la régénération — cliquer sur « rafraîchir » sur un message de l'assistant crée une branche sœur au lieu de détruire la réponse précédente.
Les primitives derrière ce comportement, déjà câblées dans votre thread.tsx généré :
<BranchPickerPrimitive.Root>
<BranchPickerPrimitive.Previous />
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
<BranchPickerPrimitive.Next />
</BranchPickerPrimitive.Root>L'état des branches vit dans l'arbre de messages interne du runtime : il survit au streaming, aux annulations et aux appels d'outils sans aucune gestion d'état de votre côté.
Étape 7 : Lire et contrôler l'état du chat
Pour les fonctionnalités au-delà des composants intégrés — une barre latérale personnalisée, de l'analytique, des déclencheurs externes — assistant-ui expose des hooks granulaires et une API de runtime.
Envoyez un message par programmation (par exemple, depuis un bouton ailleurs dans votre application) :
"use client";
import { useThreadRuntime } from "@assistant-ui/react";
export function QuickAction() {
const threadRuntime = useThreadRuntime();
return (
<button
onClick={() =>
threadRuntime.append({
role: "user",
content: [
{ type: "text", text: "Résume notre conversation jusqu'ici." },
],
})
}
className="rounded-md border px-3 py-1.5 text-sm"
>
Résumer
</button>
);
}Abonnez-vous à l'état de manière réactive — par exemple, pour désactiver la navigation pendant que le modèle génère :
import { useThread } from "@assistant-ui/react";
export function StreamingIndicator() {
const isRunning = useThread((t) => t.isRunning);
return isRunning ? (
<span className="text-xs text-muted-foreground">Génération…</span>
) : null;
}Les sélecteurs limitent les re-rendus : le composant ci-dessus ne se re-rend que lorsque isRunning change, pas à chaque token streamé.
Étape 8 : Durcissement pour la production
Un chat de démonstration et un chat de production diffèrent sur une poignée de détails critiques.
Limitation de débit
Les appels au modèle coûtent cher. Protégez votre route avant le lancement :
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, "1 m"),
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await ratelimit.limit(ip);
if (!success) {
return new Response("Limite de requêtes dépassée", { status: 429 });
}
// ... appel streamText
}Gestion des erreurs
Le runtime fait automatiquement remonter les erreurs de route dans l'interface du fil. Retournez des messages explicites plutôt que de laisser les exceptions se transformer en erreurs 500 opaques, et alignez maxDuration sur les limites de streaming de votre hébergeur (30 à 60 secondes couvrent la plupart des tours conversationnels ; les longs tours agentiques peuvent en demander plus).
Persistance
useChatRuntime conserve l'historique en mémoire par session. Pour un historique durable et multi-appareils, deux voies de production s'offrent à vous :
- Assistant Cloud — la couche de persistance managée d'assistant-ui : historique des fils, gestion de la liste des fils et flux d'approbation humaine en quelques lignes de configuration
- Auto-hébergé — persistez les tableaux
UIMessagedans le callbackonFinishdetoUIMessageStreamResponse, indexés par un identifiant de fil, puis hydratez le runtime avecinitialMessages
Quel que soit votre choix, persistez le format de message UI (pas le format modèle) — c'est la représentation sans perte qui contient les appels d'outils, les pièces jointes et les métadonnées.
Validez et autorisez toujours l'identifiant de fil côté serveur à chaque requête. Une vulnérabilité courante des applications de chat consiste à faire confiance à un identifiant fourni par le client, permettant à un utilisateur de charger l'historique de conversation d'un autre.
Tester votre implémentation
Vérifiez le flux complet :
- Streaming — envoyez « Écris un haïku sur la Tunisie » et confirmez que les tokens arrivent fluidement avec le rendu markdown
- Défilement automatique — envoyez une longue requête, remontez pendant le streaming et confirmez que le viewport arrête de suivre ; le bouton de retour en bas doit apparaître
- Interface d'outil — demandez « Quel temps fait-il à Sfax ? » et confirmez que la carte de chargement est remplacée par la carte météo
- Branchement — modifiez votre premier message, soumettez, et confirmez que le sélecteur affiche deux branches navigables
- Annulation — appuyez sur Échap (ou cliquez sur stop) en pleine génération et confirmez que le flux s'arrête proprement en conservant le message partiel
Dépannage
Les messages partent mais rien ne revient en streaming. Vérifiez que votre route retourne result.toUIMessageStreamResponse() — retourner result.toTextStreamResponse() produit un flux texte brut que le runtime ne décompose pas en parties de message.
L'interface d'outil ne s'affiche jamais. Le toolName dans makeAssistantToolUI doit correspondre exactement à la clé de l'objet tools de streamText, et le composant doit être monté dans AssistantRuntimeProvider.
Les styles Tailwind semblent cassés. Confirmez que la CLI a ajouté les chemins et variables CSS d'assistant-ui à votre configuration Tailwind ; avec Tailwind v4, vérifiez les directives @source dans votre CSS global.
Erreurs TypeScript sur UIMessage. Assurez-vous que ai et @assistant-ui/react-ai-sdk sont tous deux sur leurs dernières versions majeures — le format de messages de l'AI SDK v5 est requis par les runtimes actuels d'assistant-ui.
Prochaines étapes
- Ajoutez les pièces jointes avec les adaptateurs d'attachements d'assistant-ui (images et PDF alimentent les modèles multimodaux)
- Explorez l'approbation d'outils avec humain dans la boucle, en affichant des boutons approuver/rejeter dans une interface d'outil avant l'exécution
- Connectez des serveurs MCP via le client MCP de l'AI SDK pour donner des outils externes à votre chat sans les écrire un par un
- Lisez nos tutoriels connexes : Vercel AI Elements, Copilotes in-app avec CopilotKit et Créer des serveurs MCP en TypeScript
Conclusion
Vous avez construit une interface de chat IA de niveau production avec streaming, interfaces d'outils génératives, branchement et contrôle programmatique — avec une fraction du code qu'exigerait une construction from scratch. L'architecture en primitives d'assistant-ui vous évite le mur de personnalisation qu'imposent les widgets embarqués : chaque composant vit dans votre dépôt, stylé par votre thème Tailwind, façonné par votre produit.
Le schéma à retenir : le runtime pour l'état, les primitives pour le comportement, votre code pour l'apparence. Maîtrisez ces trois couches et n'importe quelle expérience de chat que vous pouvez esquisser — flux d'approbation, vues multi-agents, tableaux de bord intégrés — devient un après-midi de composition plutôt qu'un trimestre d'infrastructure.