Skip to content

Chapter 16 · Chatbot: useChat and Stream Protocols ​

Chapter goals:

  • Master the core usage of the useChat hook: message streaming rendering, state management, and form submission
  • Understand status, error, stop, regenerate, setMessages and other UI state control mechanisms
  • Learn to configure transport, custom headers/body, and DirectChatTransport for direct agent connection
  • Understand the difference between Text Stream and Data Stream (UI Message Stream) protocols and their wire formats

16.1 First Chat Interface ​

The useChat hook lets you easily build a conversational UI for your chat app. It supports streaming chat messages from an AI provider in real time, managing chat state, and automatically updating the UI as new messages arrive.

Core capabilities provided by useChat:

  • Message Streaming: All messages from the AI provider stream in real time to the chat UI.
  • Managed States: The hook manages input, messages, status, error, and other states for you.
  • Seamless Integration: Integrate chat AI into any design or layout with minimal effort.

Here is a complete React (Next.js App Router) example:

tsx
'use client';

import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';

export default function Page() {
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({
      api: '/api/chat',
    }),
  });
  const [input, setInput] = useState('');

  return (
    <>
      {messages.map(message => (
        <div key={message.id}>
          {message.role === 'user' ? 'User: ' : 'AI: '}
          {message.parts.map((part, index) =>
            part.type === 'text' ? <span key={index}>{part.text}</span> : null,
          )}
        </div>
      ))}

      <form
        onSubmit={e => {
          e.preventDefault();
          if (input.trim()) {
            sendMessage({ text: input });
            setInput('');
          }
        }}
      >
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          disabled={status !== 'ready'}
          placeholder="Say something..."
        />
        <button type="submit" disabled={status !== 'ready'}>
          Submit
        </button>
      </form>
    </>
  );
}

The server route is responsible for calling the model and returning the result as a UI Message Stream:

ts
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
  UIMessage,
} from 'ai';
import { createGateway } from 'ai';

const gateway = createGateway({
  apiKey: process.env.AI_GATEWAY_API_KEY ?? '',
});

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: gateway('openai/gpt-5'),
    instructions: 'You are a helpful assistant.',
    messages: await convertToModelMessages(messages),
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

💡 The same server code works with a custom provider: simply replace gateway('openai/gpt-5') with myProvider('your-model-id') (constructed with createOpenAICompatible, see Chapter 3).

Note: UI messages have a new parts property containing the different components of the message. We recommend using parts instead of content for rendering — parts supports text, tool calls, tool results, and other message types, enabling more flexible and complex chat UIs.

In the Page component, when the user sends a message via sendMessage, the useChat hook requests your AI endpoint, and the message streams back in real time to the chat UI.

16.2 State Management: status and error ​

Status ​

useChat returns a status with the following possible values:

  • submitted: Message sent to the API, waiting for the response stream to start.
  • streaming: Response is streaming in from the API.
  • ready: Full response received and processed; ready to submit a new user message.
  • error: An error occurred during the API request.

You can use status to show a loading animation while processing, display a "Stop" button to cancel the current message, disable the submit button, etc.:

tsx
'use client';

import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';

export default function Page() {
  const { messages, sendMessage, status, stop } = useChat({
    transport: new DefaultChatTransport({
      api: '/api/chat',
    }),
  });
  const [input, setInput] = useState('');

  return (
    <>
      {messages.map(message => (
        <div key={message.id}>
          {message.role === 'user' ? 'User: ' : 'AI: '}
          {message.parts.map((part, index) =>
            part.type === 'text' ? <span key={index}>{part.text}</span> : null,
          )}
        </div>
      ))}

      {(status === 'submitted' || status === 'streaming') && (
        <div>
          {status === 'submitted' && <Spinner />}
          <button type="button" onClick={() => stop()}>
            Stop
          </button>
        </div>
      )}

      <form
        onSubmit={e => {
          e.preventDefault();
          if (input.trim()) {
            sendMessage({ text: input });
            setInput('');
          }
        }}
      >
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          disabled={status !== 'ready'}
          placeholder="Say something..."
        />
        <button type="submit" disabled={status !== 'ready'}>
          Submit
        </button>
      </form>
    </>
  );
}

Error State ​

Similarly, the error state reflects the error object thrown during the fetch request. Use it to show error messages, disable the submit button, or display a retry button:

tsx
'use client';

import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';

export default function Chat() {
  const { messages, sendMessage, error, regenerate } = useChat({
    transport: new DefaultChatTransport({
      api: '/api/chat',
    }),
  });
  const [input, setInput] = useState('');

  return (
    <div>
      {messages.map(m => (
        <div key={m.id}>
          {m.role}:{' '}
          {m.parts.map((part, index) =>
            part.type === 'text' ? <span key={index}>{part.text}</span> : null,
          )}
        </div>
      ))}

      {error && (
        <>
          <div>An error occurred.</div>
          <button type="button" onClick={() => regenerate()}>
            Retry
          </button>
        </>
      )}

      <form
        onSubmit={e => {
          e.preventDefault();
          if (input.trim()) {
            sendMessage({ text: input });
            setInput('');
          }
        }}
      >
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          disabled={error != null}
        />
      </form>
    </div>
  );
}

⚠️ Show generic error messages to users (e.g., "Something went wrong") rather than leaking server-side details.

16.3 Operating Messages: Modify, Cancel, and Regenerate ​

Sometimes you may want to directly modify existing messages — for example, adding a delete button to each message. The setMessages function can handle this:

tsx
const { messages, setMessages } = useChat()

const handleDelete = (id) => {
  setMessages(messages.filter(message => message.id !== id))
}

return <>
  {messages.map(message => (
    <div key={message.id}>
      {message.role === 'user' ? 'User: ' : 'AI: '}
      {message.parts.map((part, index) => (
        part.type === 'text' ? (
          <span key={index}>{part.text}</span>
        ) : null
      ))}
      <button onClick={() => handleDelete(message.id)}>Delete</button>
    </div>
  ))}
  ...

Think of messages and setMessages as the React state / setState pair.

Cancel generation: Abort a response that is still streaming — call the stop function returned by useChat. After clicking "Stop", the fetch request is aborted, saving resources:

tsx
const { stop, status } = useChat()

return <>
  <button onClick={stop} disabled={!(status === 'streaming' || status === 'submitted')}>Stop</button>
  ...

Regenerate: Have the AI provider re-process the last message:

tsx
const { regenerate, status } = useChat();

return (
  <>
    <button
      onClick={regenerate}
      disabled={!(status === 'ready' || status === 'error')}
    >
      Regenerate
    </button>
    ...
  </>
);

After clicking "Regenerate", the provider regenerates the last message and replaces the current content.

Throttle updates: By default, every chunk triggers a re-render. Use the throttle option to throttle UI updates (currently only supported in React):

tsx
const { messages, ... } = useChat({
  // Throttle the messages and data updates to 50ms:
  throttle: 50
})

16.4 Event Callbacks and Request Configuration ​

Event Callbacks ​

useChat provides optional event callbacks covering different stages of the chat lifecycle:

  • onFinish: Triggered when the assistant's response is complete, including the response message, all messages, and abort/disconnect/error flags.
  • onError: Triggered when the fetch request encounters an error.
  • onData: Triggered when a data part is received.
tsx
import { UIMessage } from 'ai';

const {
  /* ... */
} = useChat({
  onFinish: ({ message, messages, isAbort, isDisconnect, isError }) => {
    // use information to e.g. update other UI states
  },
  onError: error => {
    console.error('An error occurred:', error);
  },
  onData: data => {
    console.log('Received data part from server:', data);
  },
});

One thing to note: throwing an error inside the onData callback will abort further processing — this triggers onError and prevents the message from being appended to the chat UI, which is useful for handling unexpected responses from the provider.

Custom Headers, Body, and Credentials ​

By default, useChat sends a POST request to /api/chat with the message list as the request body. You can configure all request-wide options at the transport layer:

tsx
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';

const { messages, sendMessage } = useChat({
  transport: new DefaultChatTransport({
    api: '/api/custom-chat',
    headers: {
      Authorization: 'your_token',
    },
    body: {
      user_id: '123',
    },
    credentials: 'same-origin',
  }),
});

You can also provide function forms for dynamic configuration, suitable for refreshing auth tokens or configurations that depend on runtime conditions:

tsx
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';

const { messages, sendMessage } = useChat({
  transport: new DefaultChatTransport({
    api: '/api/custom-chat',
    headers: () => ({
      Authorization: `Bearer ${getAuthToken()}`,
      'X-User-ID': getCurrentUserId(),
    }),
    body: () => ({
      sessionId: getCurrentSessionId(),
      preferences: getUserPreferences(),
    }),
    credentials: () => 'include',
  }),
});

For component state that changes over time, the official recommendation is to use useRef to store the current value and reference ref.current inside the config functions, or prefer request-level configuration.

Request-level options are more flexible than hook-level: each request can be customized individually, and request-level takes precedence. Pass options as the second parameter to sendMessage:

tsx
// Pass options as the second parameter to sendMessage
sendMessage(
  { text: input },
  {
    headers: {
      Authorization: 'Bearer token123',
      'X-Custom-Header': 'custom-value',
    },
    body: {
      temperature: 0.7,
      max_tokens: 100,
      user_id: '123',
    },
    metadata: {
      userId: 'user123',
      sessionId: 'session456',
    },
  },
);

Request-level options are merged with hook-level options, with request-level taking precedence. The server can destructure these additional fields from the request body:

ts
export async function POST(req: Request) {
  // Extract additional information ("customKey") from the body of the request:
  const { messages, customKey }: { messages: UIMessage[]; customKey: string } =
    await req.json();
  //...
}

16.5 Transport Configuration and Direct Agent Connection ​

The transport option lets you customize message sending behavior. For example, send only the latest message and let the server load history:

tsx
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';

export default function Chat() {
  const { messages, sendMessage } = useChat({
    id: 'my-chat',
    transport: new DefaultChatTransport({
      prepareSendMessagesRequest: ({ id, messages }) => {
        return {
          body: {
            id,
            message: messages[messages.length - 1],
          },
        };
      },
    }),
  });

  // ... rest of your component
}

The corresponding server route receives this custom format:

ts
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
} from 'ai';
import { createGateway } from 'ai';

const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY ?? '' });

export async function POST(req: Request) {
  const { id, message } = await req.json();

  // Load existing messages and add the new one
  const messages = await loadMessages(id);
  messages.push(message);

  const result = streamText({
    model: gateway('openai/gpt-5'),
    messages: await convertToModelMessages(messages),
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

For more complex scenarios like message regeneration, you can also route based on trigger: the client returns different bodies in prepareSendMessagesRequest depending on whether trigger === 'submit-user-message' or 'regenerate-assistant-message', and the server decides whether to append the message or truncate to messageId and regenerate.

DirectChatTransport ​

If you want to bypass HTTP and communicate directly with the Agent, use DirectChatTransport. This is suitable for server-side rendering, network-less testing, and single-process applications:

tsx
import { useChat } from '@ai-sdk/react';
import { DirectChatTransport, ToolLoopAgent, createGateway } from 'ai';

const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY ?? '' });

const agent = new ToolLoopAgent({
  model: gateway('openai/gpt-5'),
  instructions: 'You are a helpful assistant.',
});

export default function Chat() {
  const { messages, sendMessage, status } = useChat({
    transport: new DirectChatTransport({ agent }),
  });

  return (
    <>
      {messages.map(message => (
        <div key={message.id}>
          {message.role === 'user' ? 'User: ' : 'AI: '}
          {message.parts.map((part, index) =>
            part.type === 'text' ? <span key={index}>{part.text}</span> : null,
          )}
        </div>
      ))}

      <button
        onClick={() => sendMessage({ text: 'Hello!' })}
        disabled={status !== 'ready'}
      >
        Send
      </button>
    </>
  );
}

DirectChatTransport directly calls the agent's stream() method, converts UI messages to model messages, and streams the response back as UI message chunks.

16.6 Controlling the Response Stream: Error Propagation and Usage Info ​

When using streamText, you can control how error messages and usage information are returned to the client.

Error Messages ​

For security reasons, default error messages are masked as "An error occurred." You can forward the raw error or a custom error message by providing an onError function:

ts
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
  UIMessage,
} from 'ai';
import { createGateway } from 'ai';

const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY ?? '' });

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: gateway('openai/gpt-5'),
    messages: await convertToModelMessages(messages),
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({
      stream: result.stream,
      onError: error => {
        if (error == null) {
          return 'unknown error';
        }

        if (typeof error === 'string') {
          return error;
        }

        if (error instanceof Error) {
          return error.message;
        }

        return JSON.stringify(error);
      },
    }),
  });
}

Usage Information ​

You can track token consumption and resource usage via message metadata: define a custom metadata type with a usage field (optional, for type safety), attach usage data in the response using messageMetadata, and display usage metrics in the UI component.

ts
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
  UIMessage,
  type LanguageModelUsage,
} from 'ai';
import { createGateway } from 'ai';

const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY ?? '' });

// Create a new metadata type (optional for type-safety)
type MyMetadata = {
  totalUsage: LanguageModelUsage;
};