Skip to content

Add an AI chat to a React app

Install @aparte/react, render <AparteChat>, stream a model with useAparteClient, and put your own UI in its slots — in a React app, Next.js included.

On this page
bash
npm install @aparte/react @aparte/core
npm install @aparte/provider-scenario   # a scripted model, to stream with no key

@aparte/core, react and react-dom are peer dependencies: React ^18.0.0 || ^19.0.0.

tsx
import { AparteChat, useAparteChat } from '@aparte/react';
import '@aparte/core/styles.css';

export function Chat() {
  const chat = useAparteChat();
  return (
    <AparteChat
      ref={chat.ref}
      messages={chat.messages}
      onMessagesChange={chat.setMessages}
      emptyState={<p>Ask me anything…</p>}
      centerWhenEmpty
    />
  );
}

useAparteChat holds the messages and the component’s ref: hand both to <AparteChat>. The person’s message is added when they send, so do not add it yourself; onMessageSent fires after, for a side effect. In the Next App Router, put 'use client' at the top of the file that calls useAparteChat and registers the provider: hooks run in a client component. Next still renders it to HTML on the server, where @aparte/core resolves to its entry without elements (On the server).

tsx
import { aparteGlobalConfig } from '@aparte/core';
import { createScenarioProvider } from '@aparte/provider-scenario';
import { showcase } from '@aparte/provider-scenario/showcase';
import { AparteChat, useAparteChat, useAparteClient } from '@aparte/react';

aparteGlobalConfig.registerAIProvider(createScenarioProvider({ scenarios: showcase }));

export function Chat() {
  const chat = useAparteChat();
  useAparteClient();
  return <AparteChat ref={chat.ref} messages={chat.messages} onMessagesChange={chat.setMessages} />;
}

useAparteClient starts a client when the component mounts, stops it when it unmounts, and returns { client, abort }. It takes the AparteClientOptions and reads them once, on the first render: a function option keeps the values it closed over then, so read live state from a ref inside it, and remount the component with a new key to change the options. For a real model, register its provider, name the model and give the key — aparteGlobalConfig.setModelConfig({ defaultProvider: 'openrouter', defaultModel: 'openai/gpt-4o-mini' }) and useAparteClient({ keyResolver }) (Connect a model). A config prop gives one <AparteChat> providers and a transport of its own; give its client the same one, useAparteClient({ config }). With the client running, turn on retry and edit with aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }) (Buttons).

tsx
<AparteChat
  ref={chat.ref}
  messages={chat.messages}
  onMessagesChange={chat.setMessages}
  emptyState={<p>Ask me anything…</p>}
  toolbar={<span className="hint">Shift + Enter for a new line</span>}
/>
PropWhat it fills
emptyStatethe chat before the first message
aboveComposerthe space above the composer
toolbarthe composer’s bottom row, not drawn when you pass composer (The composer toolbar)
composerthe composer, replaced by yours
renderBubble(message)each message, drawn by you (Bubble)

The component takes these and no children.

tsx
<AparteChat id="support" className="flex-1 min-h-0" loading={isFetchingHistory} attachments />

The ones you reach for: placeholder, disabled and submitOnEnter; centerWhenEmpty; loading, for a wait of your own; isTyping and typingText, a waiting line you drive yourself; overlayComposer; attachments, off by default (Attachments); elicitation={false} when you draw the questions yourself; conversationId, config and layoutTransitionMs, how long the list holds its layout after a conversation swap. The root takes className and style. id is the chat’s id, which every event carries: for two chats on one page, each answered by its own client, give each an id and its client scopeToTargetId with it. The callbacks — onMessageSent, onAction, onMessagesChange, onMessageAppended, onTypingChange and onConversationCreated — are beside the other frameworks’ in Wrapper surface.

chat.ref, and the helpers useAparteChat returns beside it, are the chat’s imperative API: appendMessage, addSegment, injectTokenStream, scrollToBottom, focusInput and the rest, to drive the chat from a loop of yours (Your own loop). useConversationManager() lists, creates, archives and pins saved conversations once you call init(adapter) with your storage adapter — and the chat’s config as second argument, if it has one — and the chat switches between them through conversationId (Save conversations).

tsx
<aparte-select searchable="" placeholder="Pick a model">
  <aparte-option value="gpt-4o-mini">GPT-4o mini</aparte-option>
</aparte-select>

Importing from @aparte/react types every element of core as JSX: the attribute names are the HTML ones, a presence attribute takes '' to be on and null or undefined to be off, and events are read by ref, typed through the DOM (Elements, typed). A plugin’s element is typed by the plugin, and one of yours by you (your own element). <AparteUi> mounts any tag and sets its props; it forwards core’s events, so name your element’s own in events. A ref to it gives getElement() and callMethod(name, ...args):

tsx
<AparteUi name="my-token-counter" props={{ 'data-budget': '8000' }} events={['token-count']} onElementEvent={(e) => setCount(e.detail)} />

Vitest runs on Node, where @aparte/core resolves to its entry without elements, so a tag never upgrades under jsdom: alias it to @aparte/core/browser (Testing your components).

A whole chat site in React — the conversation sidebar, the header, settings, tools and the scripted model — is the React example.