Learn how to store and load chat messages in a chatbot.
Being able to store and load chat messages is crucial for most AI chatbots.
In this guide, we'll show how to implement message persistence with useChat and streamText.
This guide does not cover authorization, error handling, or other real-world
considerations. It is intended to be a simple example of how to implement
message persistence.
When the user navigates to the chat page without providing a chat ID,
we need to create a new chat and redirect to the chat page with the new chat ID.
app/chat/page.tsx
import { redirect } from 'next/navigation';import { createChat } from '@util/chat-store';export default async function Page() { const id = await createChat(); // create a new chat redirect(`/chat/${id}`); // redirect to chat page, see below}
Our example chat store implementation uses files to store the chat messages.
In a real-world application, you would use a database or a cloud storage service,
and get the chat ID from the database.
That being said, the function interfaces are designed to be easily replaced with other implementations.
util/chat-store.ts
import { generateId } from 'ai';import { existsSync, mkdirSync } from 'fs';import { writeFile } from 'fs/promises';import path from 'path';// Treat chat IDs as opaque tokens before using them in file paths.const chatIdRegex = /^[A-Za-z0-9_-]+$/;export async function createChat(): Promise<string> { const id = generateId(); // generate a unique chat ID await writeFile(getChatFile(id), '[]'); // create an empty chat file return id;}function getChatFile(id: string): string { if (!chatIdRegex.test(id)) { throw new Error('Invalid chat ID'); } const chatDir = path.resolve(process.cwd(), '.chats'); const chatFile = path.resolve(chatDir, `${id}.json`); // Defense in depth: keep the resolved file inside the chat directory. if (!chatFile.startsWith(`${chatDir}${path.sep}`)) { throw new Error('Invalid chat ID'); } if (!existsSync(chatDir)) mkdirSync(chatDir, { recursive: true }); return chatFile;}
The chat ID can come from the URL or the request body, so validate it as an
opaque token before using it in a file path. The resolved path check ensures the
file stays inside the intended .chats directory.
When processing messages on the server that contain tool calls, custom metadata, or data parts, you should validate them using validateUIMessages before sending them to the model.
An input-streaming tool part includes rawInput while tool arguments are
still arriving. Persist the complete UI message, including this field, so a
resumed stream can append later tool-input deltas to the exact text received
before the interruption. AI SDK updates rawInput as each delta arrives and
removes it when the tool input becomes available.
Older persisted tool parts in the output-error state may contain rawInput.
This field is deprecated and will be removed in the next major version. Store
tool arguments in input instead. AI SDK emits a deprecation warning when
validateUIMessages, safeValidateUIMessages, or convertToModelMessages
encounters a defined rawInput value. UI stream processing also warns when it
reconstructs a static output-error part with rawInput.
Migrate each legacy part by copying rawInput to input when input is
null or undefined, then remove rawInput. This preserves the current
backward-compatible conversion behavior:
useChat sends the chat id and the messages to the backend.
The useChat message format is different from the ModelMessage format. The
useChat message format is designed for frontend display, and contains
additional fields such as id and createdAt. We recommend storing the
messages in the useChat message format.
When loading messages from storage that contain tools, metadata, or custom data
parts, validate them using validateUIMessages before processing (see the
validation section above).
Storing messages is done in the onEnd callback of the toUIMessageStream function.
onEnd receives the complete messages including the new AI response as UIMessage[].
By default, message IDs are generated client-side:
User message IDs are generated by the useChat hook on the client
AI response message IDs are also generated by useChat on the client, unless
the server sends one in the stream (see below)
For applications without persistence, client-side ID generation works perfectly.
However, for persistence, you should use IDs that are stable before messages
are stored to ensure consistency across sessions and prevent ID conflicts when
messages are restored.
The server-side options below control IDs for generated assistant response
messages. User message IDs are created by useChat before the request is sent
to your API route, so keep those client-generated IDs when saving incoming
request messages, or generate and persist your own user message IDs before
sending/storing them.
Once you have implemented message persistence, you might want to send only the last message to the server.
This reduces the amount of data sent to the server on each request and can improve performance.
To achieve this, you can provide a prepareSendMessagesRequest function to the transport.
This function receives the messages and the chat ID, and returns the request body to be sent to the server.
ui/chat.tsx
import { useChat } from '@ai-sdk/react';import { DefaultChatTransport } from 'ai';const { // ...} = useChat({ // ... transport: new DefaultChatTransport({ api: '/api/chat', // only send the last message to the server: prepareSendMessagesRequest({ messages, id }) { return { body: { message: messages[messages.length - 1], id } }; }, }),});
On the server, you can then load the previous messages and append the new message to the previous messages. If your messages contain tools, metadata, or custom data parts, you should validate them:
app/api/chat/route.ts
import { convertToModelMessages, createUIMessageStreamResponse, toUIMessageStream, UIMessage, validateUIMessages,} from 'ai';// import your tools and schemasexport async function POST(req: Request) { // get the last message from the client: const { message, id } = await req.json(); // load the previous messages from the server: const previousMessages = await loadChat(id); // validate messages if they contain tools, metadata, or data parts: const validatedMessages = await validateUIMessages({ // append the new message to the previous messages: messages: [...previousMessages, message], tools, // if using tools metadataSchema, // if using custom metadata dataSchemas, // if using custom data parts }); const result = streamText({ // ... messages: await convertToModelMessages(validatedMessages), }); return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream, originalMessages: validatedMessages, onEnd: ({ messages }) => { saveChat({ chatId: id, messages }); }, }), });}
By default, the AI SDK streamText function uses backpressure to the language model provider to prevent
the consumption of tokens that are not yet requested.
However, this means that when the client disconnects, e.g. by closing the browser tab or because of a network issue,
the stream from the LLM will be aborted and the conversation may end up in a broken state.
Assuming that you have a storage solution in place, you can use the consumeStream method to consume the stream on the backend,
and then save the result as usual.
consumeStream effectively removes the backpressure,
meaning that the result is stored even when the client has already disconnected.
app/api/chat/route.ts
import { convertToModelMessages, createUIMessageStreamResponse, streamText, toUIMessageStream, UIMessage,} from 'ai';import { saveChat } from '@util/chat-store';export async function POST(req: Request) { const { messages, chatId }: { messages: UIMessage[]; chatId: string } = await req.json(); const result = streamText({ model, messages: await convertToModelMessages(messages), }); // consume the stream to ensure it runs to completion & triggers onEnd // even when the client response is aborted: result.consumeStream(); // no await return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream, originalMessages: messages, onEnd: ({ messages }) => { saveChat({ chatId, messages }); }, }), });}
When the client reloads the page after a disconnect, the chat will be restored from the storage solution.
In production applications, you would also track the state of the request (in
progress, complete) in your stored messages and use it on the client to cover
the case where the client reloads the page after a disconnection, but the
streaming is not yet complete.
For more robust handling of disconnects, you may want to add resumability on disconnects. Check out the Chatbot Resume Streams documentation to learn more.