Chaque application IA livrée en 2025 a fini par réinventer la même interface de chat : un conteneur de défilement qui s'épingle automatiquement en bas, des bulles de message qui changent selon le rôle, un textarea qui grandit, un bouton d'envoi qui se transforme en bouton stop pendant le streaming, un panneau de raisonnement repliable, des chips de pièces jointes. Rien de tout cela n'est difficile, mais l'ensemble dévore un sprint et le résultat reste légèrement bancal.
Vercel AI Elements est la réponse à ce problème. C'est une bibliothèque de composants — bâtie sur shadcn/ui et accordée pour AI SDK — qui livre chacune de ces primitives sous forme de composants React installables que vous possédez dans votre dépôt. Vous lancez une commande CLI, le code source atterrit dans components/ai-elements, et vous le branchez à useChat. À la fin de ce tutoriel, vous aurez une application de chat Next.js 15 avec réponses en streaming, volets de raisonnement, pièces jointes, sélecteur de modèle et bouton de recherche web — sans écrire la moindre ligne d'UI à partir de zéro.
AI Elements n'est pas une boîte noire. Contrairement à la plupart des bibliothèques de chat, AI Elements ne se livre pas via npm install. Il utilise le pattern shadcn : la CLI copie de vrais fichiers source dans votre projet. Vous pouvez tout éditer, tout restyler, supprimer ce dont vous n'avez pas besoin. Vercel maintient le registry, vous possédez le code.
Ce que vous allez apprendre
À la fin de ce tutoriel, vous saurez :
- Installer les composants AI Elements dans un projet Next.js 15 App Router via la CLI du registry
- Construire une route
/api/chatavec les helpersstreamTextettoUIMessageStreamResponsede AI SDK 5 - Brancher
Conversation,MessageetMessageResponsepour rendre la sortie IA en streaming - Utiliser
PromptInputavecPromptInputTextarea,PromptInputSubmitetPromptInputBodypour un input soigné - Ajouter des pièces jointes avec
PromptInputActionAddAttachmentset les primitivesAttachments - Afficher le raisonnement du modèle avec
Reasoning,ReasoningTriggeretReasoningContent - Afficher les appels d'outils et les sources web avec
Tool,SourceetSources
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20 ou plus récent (le build ESM moderne de AI SDK 5 refuse les versions antérieures)
- Une familiarité avec Next.js 15 App Router (composants serveur vs composants client)
- Une clé OpenAI ou Anthropic API — nous utiliserons OpenAI mais le swap tient en une ligne
- shadcn/ui déjà initialisé (ou laissez la CLI AI Elements l'initialiser)
- De l'aisance avec les hooks React et TypeScript
Ce que vous allez construire
Une application de chat à page unique appelée Noqta Chat, avec un fil de conversation réel, un streaming texte, un raisonnement extensible, des pièces jointes images et PDF, un sélecteur de modèle entre GPT-4o et Claude Sonnet 4.6, et un bouton "Rechercher sur le web" qui change le comportement côté serveur. Toute l'interface sera composée à partir des primitives AI Elements vivant dans votre dossier components/ai-elements/.
Étape 1 : configuration du projet
Lancez un nouveau projet Next.js 15 avec TypeScript et Tailwind :
npx create-next-app@latest noqta-chat \
--typescript --tailwind --app --eslint --src-dir
cd noqta-chatInitialisez shadcn/ui (AI Elements en a besoin comme couche de base) :
npx shadcn@latest initChoisissez Neutral comme couleur de base et CSS variables lors de l'invite. Cela configure globals.css, lib/utils.ts et components.json.
Maintenant, installez AI Elements. Le chemin le plus rapide est la CLI dédiée, qui ajoute tous les composants d'un coup :
npx ai-elements@latestDe nouveaux fichiers devraient apparaître dans components/ai-elements/ : conversation.tsx, message.tsx, prompt-input.tsx, reasoning.tsx, tool.tsx, source.tsx, attachments.tsx, code-block.tsx et quelques autres. Ce sont de vrais fichiers source — ouvrez-en un et vous verrez des composants standards façon shadcn bâtis sur des primitives Radix.
Vous préférez installer un composant à la fois ? Utilisez npx ai-elements@latest add conversation pour n'ajouter que les primitives de conversation, ou utilisez directement le registry shadcn sous-jacent : npx shadcn@latest add https://elements.ai-sdk.dev/api/registry/all.json. Les deux produisent un résultat identique.
Étape 2 : installer AI SDK 5
AI Elements est conçu pour se brancher au hook useChat de AI SDK 5. Installez le runtime, les bindings React et au moins un provider :
npm install ai @ai-sdk/react @ai-sdk/openai @ai-sdk/anthropic zodAjoutez vos clés à .env.local :
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...Redémarrez npm run dev pour que Next.js prenne en compte les nouvelles variables d'environnement.
Étape 3 : la route API du chat
AI SDK 5 streame des messages d'interface — un format structuré où chaque message est une liste de parties typées (texte, raisonnement, outil, source, fichier). Les composants AI Elements lisent directement ces parties, donc le serveur n'a qu'à transférer le stream du modèle en tant que stream de messages d'interface.
Créez src/app/api/chat/route.ts :
import { openai } from "@ai-sdk/openai";
import { anthropic } from "@ai-sdk/anthropic";
import {
convertToModelMessages,
streamText,
type UIMessage,
} from "ai";
export const maxDuration = 30;
type ChatBody = {
messages: UIMessage[];
model?: string;
webSearch?: boolean;
};
export async function POST(req: Request) {
const { messages, model = "gpt-4o", webSearch = false }: ChatBody =
await req.json();
const provider = model.startsWith("claude")
? anthropic(model)
: openai(model);
const result = streamText({
model: provider,
system: webSearch
? "You can search the web. Cite sources you used."
: "You are a helpful assistant. Be concise and accurate.",
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse({
sendReasoning: true,
sendSources: true,
});
}Deux points comptent ici :
convertToModelMessagestransforme le format de message d'interface (tableau de parties) en format de chat completion attendu par chaque provider.toUIMessageStreamResponseproduit le stream encadré queuseChatet AI Elements consomment. PassersendReasoning: trueest ce qui fait apparaître le raisonnement étendu de Claude ;sendSources: truelaisse les résultats de recherche web circuler comme partiessource.
Étape 4 : la coque de conversation minimale
Remplacez src/app/page.tsx par un Client Component qui rend le squelette de la conversation. Nous démarrons au minimum et empilons les fonctionnalités dans les étapes suivantes.
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import {
PromptInput,
type PromptInputMessage,
PromptInputTextarea,
PromptInputSubmit,
} from "@/components/ai-elements/prompt-input";
import { MessageSquare } from "lucide-react";
export default function ChatPage() {
const [input, setInput] = useState("");
const { messages, sendMessage, status } = useChat();
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim()) return;
sendMessage({ text: message.text });
setInput("");
};
return (
<main className="mx-auto flex h-dvh max-w-3xl flex-col p-6">
<Conversation className="flex-1">
<ConversationContent>
{messages.length === 0 ? (
<ConversationEmptyState
icon={<MessageSquare className="size-12" />}
title="Start a conversation"
description="Ask Noqta Chat anything"
/>
) : (
messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent>
{message.parts.map((part, i) =>
part.type === "text" ? (
<MessageResponse key={`${message.id}-${i}`}>
{part.text}
</MessageResponse>
) : null
)}
</MessageContent>
</Message>
))
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput onSubmit={handleSubmit} className="mt-4">
<PromptInputTextarea
value={input}
placeholder="Send a message..."
onChange={(e) => setInput(e.currentTarget.value)}
/>
<PromptInputSubmit
status={status === "streaming" ? "streaming" : "ready"}
disabled={!input.trim()}
/>
</PromptInput>
</main>
);
}Lancez npm run dev, ouvrez http://localhost:3000, et vous avez déjà un chat fonctionnel. Conversation gère le défilement automatique, ConversationScrollButton affiche une pastille "aller au plus récent" quand vous remontez, et MessageResponse gère le rendu markdown en streaming. La coque entière fait moins de 60 lignes de JSX.
Étape 5 : itérer sur les parties de message correctement
Le pattern de l'étape 4 ne rend que les parties text. Les vraies réponses incluent aussi des parties reasoning, tool-*, source-url et file. Refactorez le rendu de message dans un composant dédié pour qu'il grandisse proprement à chaque étape.
Créez src/components/chat-message-parts.tsx :
"use client";
import type { UIMessage } from "ai";
import {
MessageResponse,
} from "@/components/ai-elements/message";
export function ChatMessageParts({ message }: { message: UIMessage }) {
return (
<>
{message.parts.map((part, i) => {
const key = `${message.id}-${i}`;
switch (part.type) {
case "text":
return <MessageResponse key={key}>{part.text}</MessageResponse>;
default:
return null;
}
})}
</>
);
}Remplacez le .map inline dans page.tsx par <ChatMessageParts message={message} />. Désormais, chaque nouveau type de partie qu'on branche ajoute un case ici au lieu de ramifier la page.
Étape 6 : streaming du raisonnement
Claude Sonnet 4.6 et les modèles GPT à raisonnement émettent un stream de partie reasoning séparé avant le texte final. AI Elements vous donne une révélation repliable pour cela.
Mettez à jour src/components/chat-message-parts.tsx :
"use client";
import type { UIMessage } from "ai";
import { MessageResponse } from "@/components/ai-elements/message";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
type Props = {
message: UIMessage;
isLastMessage: boolean;
isStreaming: boolean;
};
export function ChatMessageParts({ message, isLastMessage, isStreaming }: Props) {
const reasoningParts = message.parts.filter((p) => p.type === "reasoning");
const reasoningText = reasoningParts.map((p) => p.text).join("\n\n");
const lastPart = message.parts.at(-1);
const isReasoningStreaming =
isLastMessage && isStreaming && lastPart?.type === "reasoning";
return (
<>
{reasoningParts.length > 0 && (
<Reasoning className="w-full" isStreaming={isReasoningStreaming}>
<ReasoningTrigger />
<ReasoningContent>{reasoningText}</ReasoningContent>
</Reasoning>
)}
{message.parts.map((part, i) => {
const key = `${message.id}-${i}`;
if (part.type === "text") {
return <MessageResponse key={key}>{part.text}</MessageResponse>;
}
return null;
})}
</>
);
}Passez les drapeaux de streaming depuis page.tsx :
{messages.map((message, index) => (
<Message from={message.role} key={message.id}>
<MessageContent>
<ChatMessageParts
message={message}
isLastMessage={index === messages.length - 1}
isStreaming={status === "streaming"}
/>
</MessageContent>
</Message>
))}Reasoning s'ouvre automatiquement pendant le streaming, anime un scintillement de tokens et se replie quand le modèle a fini de réfléchir — ce comportement de fermeture automatique est la partie que tout le monde rate en faisant l'UI à la main.
Étape 7 : un PromptInput soigné avec barre d'outils
Le PromptInput minimal de l'étape 4 est correct, mais la vraie valeur d'AI Elements ce sont les primitives de mise en page qui se composent en barre d'outils façon Claude. Remplacez votre input par un layout en-tête-corps-pied :
import {
PromptInput,
PromptInputBody,
PromptInputButton,
PromptInputFooter,
type PromptInputMessage,
PromptInputSelect,
PromptInputSelectContent,
PromptInputSelectItem,
PromptInputSelectTrigger,
PromptInputSelectValue,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from "@/components/ai-elements/prompt-input";
import { GlobeIcon } from "lucide-react";
const models = [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
];
// inside ChatPage:
const [model, setModel] = useState(models[0].id);
const [webSearch, setWebSearch] = useState(false);
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text.trim() && !message.files?.length) return;
sendMessage(
{ text: message.text, files: message.files },
{ body: { model, webSearch } }
);
setInput("");
};
return (
// ...Conversation au-dessus...
<PromptInput onSubmit={handleSubmit} className="mt-4" globalDrop multiple>
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
placeholder="Ask anything..."
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputButton
onClick={() => setWebSearch(!webSearch)}
variant={webSearch ? "default" : "ghost"}
tooltip={{ content: "Search the web" }}
>
<GlobeIcon size={16} />
<span>Search</span>
</PromptInputButton>
<PromptInputSelect value={model} onValueChange={setModel}>
<PromptInputSelectTrigger>
<PromptInputSelectValue />
</PromptInputSelectTrigger>
<PromptInputSelectContent>
{models.map((m) => (
<PromptInputSelectItem key={m.id} value={m.id}>
{m.name}
</PromptInputSelectItem>
))}
</PromptInputSelectContent>
</PromptInputSelect>
</PromptInputTools>
<PromptInputSubmit
status={status === "streaming" ? "streaming" : "ready"}
/>
</PromptInputFooter>
</PromptInput>
);Trois choses viennent de se produire :
- Le paramètre
bodysursendMessagetransmet{ model, webSearch }à votre route handler — c'est ainsi que les champs body de l'étape 3 se remplissent réellement. PromptInputSubmitbascule automatiquement entre une icône d'avion en papier et un carré stop quandstatus === "streaming". Cliquer la variante stop appelle l'abort deuseChatsous le capot.globalDrop multipleactive le glisser-déposer de pièces jointes n'importe où sur la page, ce que nous activons ensuite.
Étape 8 : pièces jointes
AI Elements fournit une primitive Attachments qui se marie avec PromptInputActionAddAttachments. Le hook usePromptInputAttachments expose l'état vivant des pièces jointes.
Créez src/components/attachments-display.tsx :
"use client";
import {
Attachment,
AttachmentPreview,
AttachmentRemove,
Attachments,
} from "@/components/ai-elements/attachments";
import { usePromptInputAttachments } from "@/components/ai-elements/prompt-input";
export function AttachmentsDisplay() {
const attachments = usePromptInputAttachments();
if (attachments.files.length === 0) return null;
return (
<Attachments variant="inline">
{attachments.files.map((attachment) => (
<Attachment
data={attachment}
key={attachment.id}
onRemove={() => attachments.remove(attachment.id)}
>
<AttachmentPreview />
<AttachmentRemove />
</Attachment>
))}
</Attachments>
);
}Étendez maintenant le bloc PromptInput dans page.tsx :
import {
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputHeader,
} from "@/components/ai-elements/prompt-input";
import { AttachmentsDisplay } from "@/components/attachments-display";
// dans <PromptInput>:
<PromptInputHeader>
<AttachmentsDisplay />
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea ... />
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{/* ...bouton de recherche web, sélecteur de modèle... */}
</PromptInputTools>
<PromptInputSubmit ... />
</PromptInputFooter>Déposez un PNG ou un PDF sur la page. Vous verrez le fichier rendu comme une chip au-dessus du textarea, et à la soumission il atterrit dans message.files — que AI SDK 5 transmet au modèle en tant que partie file.
Étape 9 : afficher les appels d'outils
Quand le modèle invoque un outil (outil custom, recherche web, interpréteur de code), le stream émet des parties de type tool-<toolName>. AI Elements fournit un composant Tool qui rend le nom de l'appel, l'état, l'entrée et la sortie sous forme de carte repliée nette.
Ajoutez à chat-message-parts.tsx :
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "@/components/ai-elements/tool";
// dans le switch parts.map:
default: {
if (part.type.startsWith("tool-")) {
const toolPart = part as Extract<typeof part, { type: `tool-${string}` }>;
return (
<Tool key={key} defaultOpen={toolPart.state !== "output-available"}>
<ToolHeader type={toolPart.type} state={toolPart.state} />
<ToolContent>
<ToolInput input={toolPart.input} />
{toolPart.output !== undefined && (
<ToolOutput
output={toolPart.output}
errorText={toolPart.errorText}
/>
)}
</ToolContent>
</Tool>
);
}
return null;
}ToolHeader affiche une pastille d'état — "input-streaming", "input-available", "output-available", "output-error" — pour que l'utilisateur voie l'agent réfléchir, pas une UI gelée.
Étape 10 : sources de recherche web
Dès que sendSources: true est paramétré sur la réponse de la route (étape 3), les modèles capables de chercher sur le web streament des parties source-url que vous pouvez rendre comme un pied de citation.
import {
Source,
Sources,
SourcesContent,
SourcesTrigger,
} from "@/components/ai-elements/source";
// en haut du return de ChatMessageParts:
const sourceParts = message.parts.filter((p) => p.type === "source-url");
{sourceParts.length > 0 && (
<Sources>
<SourcesTrigger count={sourceParts.length} />
<SourcesContent>
{sourceParts.map((s, i) => (
<Source
key={`${message.id}-src-${i}`}
href={s.url}
title={s.title ?? s.url}
/>
))}
</SourcesContent>
</Sources>
)}Sources est un panneau repliable sous la réponse de l'assistant avec une entrée par URL citée. Cliquer chaque chip ouvre la source dans un nouvel onglet — le pattern standard popularisé par Perplexity.
Tester votre implémentation
- Envoyez "Quelle est la capitale de la Tunisie ?" — confirmez que l'assistant streame le texte de haut en bas sans à-coups de défilement.
- Basculez le sélecteur de modèle vers Claude Sonnet 4.6 et posez une question de maths — confirmez que le panneau de raisonnement s'ouvre, s'anime pendant la réflexion et se replie à la fin.
- Activez le bouton Search et posez une question d'actualité — confirmez que le menu déroulant Sources apparaît sous la réponse.
- Glissez une petite image sur la page — confirmez qu'une chip de pièce jointe apparaît, soumettez et vérifiez que le modèle réfère à l'image dans sa réponse.
- En plein streaming, cliquez le bouton stop sur
PromptInputSubmit— confirmez que le streaming s'arrête et que la réponse partielle reste en place.
Dépannage
Cannot find module '@/components/ai-elements/conversation' — la CLI AI Elements n'a pas tourné. Relancez npx ai-elements@latest depuis la racine de votre projet et confirmez que les fichiers apparaissent dans src/components/ai-elements/.
Le panneau de raisonnement ne s'ouvre jamais — votre route oublie sendReasoning: true sur toUIMessageStreamResponse, ou le modèle sélectionné n'émet pas de raisonnement. Testez avec claude-sonnet-4-6.
Les pièces jointes échouent avec une erreur 413 — les routes Next.js acceptent par défaut des corps de requête jusqu'à 1 Mo. Ajoutez export const runtime = "nodejs" et augmentez la limite dans next.config.ts sous experimental.serverActions.bodySizeLimit.
Erreurs de type UIMessage après mise à niveau d'AI SDK — AI SDK 5 a renommé le champ content en parts. Assurez-vous que votre version de @ai-sdk/react est au moins 2.x ; les composants AI Elements dépendent de la forme à parties.
Prochaines étapes
- Couplez cette interface au tutoriel Mastra AI agents pour donner au chat de vrais outils, de la mémoire et un moteur de workflow.
- Ajoutez Resend + React Email pour envoyer une transcription à la fin de la conversation.
- Enveloppez le chat dans une sidebar CopilotKit pour qu'il vive à côté de votre application au lieu d'occuper toute la page.
- Persistez les conversations dans Drizzle + Neon clés par utilisateur — AI Elements n'a rien à voir avec la persistance, le choix vous appartient.
Conclusion
Le travail intéressant dans une application IA, c'est la boucle d'agent, la conception de prompts, les outils, les évaluations. L'UI est résolue — mais seulement si vous arrêtez de la résoudre vous-même. AI Elements n'est pas un framework qu'on adopte, c'est un registry depuis lequel on copie. Une fois les composants dans votre dépôt ils sont à vous : restylez-les avec vos tokens, échangez les icônes, supprimez les parties de PromptInput dont vous n'avez pas besoin, et passez aux parties de votre produit qui vous différencient vraiment. Dépensez votre sprint sur l'agent, pas sur le comportement de défilement automatique.