Skip to content
Miss Blue
Miss Blue field notes

Message persistence with the Vercel AI SDK

Streaming and storage are different problems, and the AI SDK deliberately separates them. Get the four moving parts right — chat identity, stable message IDs, a server-side save point, and a validated load — and history survives reloads, disconnects, and redeploys.

Published August 27, 2026Updated September 13, 202614 minute read
Build with Miss BlueTurn the next message into a real reply.

Get a blue line for your product, agent, or team. Use the Message Center today and connect the API anytime.

Create your account Explore the API
01

Run a local database example first

The stream API examples below target AI SDK 7 (ai 7.0.99 and @ai-sdk/react 4.0.102), with the official persistence guide checked September 13, 2026. Pin your own dependency versions and compare the reference for that version before copying a callback name.

Download the standalone storage demonstration and run it with Node.js 22.13 or later. It creates a temporary SQLite database, reserves an assistant ID before generation, rejects a conflicting retry, allows one active turn per chat, and reloads the same two messages after reopening the database. No model key or network request is needed.

This demonstrates durable storage behavior for UIMessage-shaped records. It is not a complete web app: add authenticated chat ownership, input limits, SDK schema validation, async database access appropriate to your server, and recovery for interrupted generations before using the pattern in production.

node chat-persistence.mjs
# Passed: stable IDs, retry conflict, one active turn, restart,
# duplicate completion, history reload.
02

Why persistence is its own problem

A streaming chat route is a pipe. Tokens arrive, the client renders them, and when the response ends the pipe closes. Nothing in that flow writes anything down. Refresh the page and the conversation is gone, because it only ever existed in React state.

The AI SDK keeps this separation on purpose: the stream is a transport concern, and storage is yours. What the SDK provides is a set of hooks at exactly the points where saving is safe — a place to generate stable IDs, a callback that fires with the finished message list, and a way to keep the stream running after the client disappears.

03

The four moving parts

Every persistent chat implementation, in any framework, needs the same four things. Name them before writing code and the rest falls out.

Most bugs in this area are one of these four missing: IDs that change between render and save, a save that fires on the client and therefore never runs when the tab closes, or a load that trusts whatever is in the database.

  • A chat ID that exists before the first message is sent
  • Message IDs generated once, server-side, and never regenerated
  • A save that runs on the server when the stream ends
  • A load that validates what comes back out of storage
04

Create the chat before the first message

Give the conversation an identity up front. Creating the record on page load and redirecting to its URL means the chat ID is in the address bar, in the client component, and in every request body from the very first turn.

The alternative — creating the chat lazily on first send — leaves a window where a message exists with nothing to attach it to, and it is the reason first messages go missing under a fast double-submit.

// app/chat/page.tsx
import { redirect } from 'next/navigation';
import { createChat } from '@util/chat-store';

export default async function Page() {
  // Your createChat implementation must bind the chat to the signed-in user.
  const id = await createChat();
  redirect(`/chat/${id}`);
}
05

Load history on the server, hand it to the client

The chat page reads the stored messages in a server component and passes them into the client component as initial state. useChat accepts them through its messages option alongside the chat id.

Because the load happens on the server, the first paint already contains the conversation. There is no flash of an empty thread while a client-side fetch resolves.

// app/chat/[id]/page.tsx
import { loadChat } from '@util/chat-store';
import Chat from '@ui/chat';

export default async function Page(props: { params: Promise<{ id: string }> }) {
  const { id } = await props.params;
  // loadChat must enforce the signed-in user’s ownership of this chat.
  const messages = await loadChat(id);
  return <Chat id={id} initialMessages={messages} />;
}
06

Send the last message, not the whole thread

By default the client posts the entire message array on every turn. Once the server is the source of truth, that is wasted bandwidth and a second copy of state that can disagree with the first. prepareSendMessagesRequest on DefaultChatTransport lets you send only the newest message plus the chat ID.

The server then reloads the prior messages from storage and appends the incoming one. Storage becomes the single authority for what the conversation contains, which is what makes the rest of this reliable.

// ui/chat.tsx
'use client';

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

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

  // render messages, call sendMessage({ text })
}
07

Generate message IDs on the server

The client’s user-message ID can identify a logical request after validation and ownership checks. Reserve the assistant ID in your database before generation and supply that value through generateMessageId. A random generator is useful when reserving a new ID; calling it again on every retry does not preserve identity.

Skip this and the ID assigned during streaming can differ from the ID written at save time. The symptom is duplicated assistant messages after a reload, which is much harder to diagnose than it is to prevent.

08

Save when the stream ends, on the server

toUIMessageStream accepts originalMessages, generateMessageId, and an onEnd callback that receives the complete message list including the new assistant response. Saving on the server avoids depending on a client-side effect. Stream consumption and the hosting runtime must also keep the work alive after a client disconnects.

createUIMessageStreamResponse wraps the result for the route to return. The snippet illustrates the stream-to-save boundary. The storage helpers, authorization, validation, request deduplication, and interrupted-turn recovery must be implemented by your application; the downloadable example demonstrates the storage part.

// app/api/chat/route.ts
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
  UIMessage,
} from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { loadChat, saveChat, reserveAssistantId } from '@util/chat-store';

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

  // Illustrative composition: authorize chat ownership and validate input
  // before this point. Reserve the turn/assistant ID in durable storage.
  const previousMessages = await loadChat(id);
  const messages = [...previousMessages, message];
  // reserveAssistantId must return the same persisted ID on a safe retry.
  const assistantId = await reserveAssistantId(id, message.id);

  const result = streamText({
    model: anthropic(process.env.ANTHROPIC_MODEL!), // choose an enabled model
    messages: await convertToModelMessages(messages),
  });

  result.consumeStream();

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({
      stream: result.stream,
      originalMessages: messages,
      generateMessageId: () => assistantId,
      onEnd: async ({ messages }) => {
        await saveChat({ chatId: id, messages });
      },
    }),
  });
}
09

Handle the client that walks away

A user closes the tab halfway through a long answer. Without intervention, backpressure stops the generation and you store a truncated response — or nothing at all. result.consumeStream() drains the stream regardless of whether anyone is reading it, so the model finishes and onEnd still fires.

consumeStream does not make a server process immortal. A deployment, execution timeout, or worker crash can still interrupt generation. Persist the user turn first, track pending work, and use a durable worker when completion must survive request-lifetime limits.

10

Validate what you load back

Stored messages are input. They were written by an earlier version of your code, possibly with tools that no longer exist or schemas that have since changed, and they flow straight back into the model on the next turn.

validateUIMessages checks persisted messages against your current tools and schemas before they are used. Treat a validation failure as a recoverable event — preserve the original record and repair or quarantine the offending state before replaying it, because a single bad row should not make a chat permanently unopenable.

  • Validate on load, not only on write
  • Version the stored shape so migrations are possible
  • Preserve originals; repair or quarantine invalid state before model replay
  • Never trust stored tool results as though they were fresh
11

Choosing storage

The access pattern is narrow: read all messages for one chat ID, append messages to one chat ID. Almost anything satisfies it. A relational table with a chat row and a messages row is the default and stays correct as requirements grow; a document store works if you always read the whole thread.

Store the message array as it comes out of onEnd rather than shredding it into a bespoke schema. The SDK’s message shape includes parts, tool calls, and metadata that a hand-rolled columns-for-role-and-text table silently discards.

  • One row per chat, one row per message, indexed by chat ID
  • Persist the full message structure, not a flattened text field
  • Keep a created-at and an updated-at for pagination and cleanup
  • Decide retention deliberately — conversation logs age into liability
12

Idempotency and duplicate writes

Retries happen: a client resubmits, a proxy replays, a deploy interrupts a request. If onEnd runs twice for the same turn you want the second write to be a no-op, not a duplicate message.

A random ID generator makes an ID for one run; it does not make a retried request reuse that ID. Persist a request key and reserve the assistant ID before starting generation. A duplicate completed request returns the stored result, while a pending request is reconciled instead of starting a second model run. Upserts help only after identity is stable across attempts.

13

The same pattern outside the browser

Persistence stops being optional the moment the conversation leaves the page. In a browser chat, the transcript is a convenience — the user can see what was said. In a messaging channel, the transcript is the only state that exists: replies arrive hours later, from a phone, through a webhook, with no session and no React tree to hold anything.

The mapping is direct. The chat ID becomes the conversation with a contact, DefaultChatTransport becomes an inbound webhook plus an outbound send, and onEnd becomes the point where you write the turn before you send it. Miss Blue supplies the iMessage side of that — a line, conversation events, and a shared Message Center where a person can read the same stored thread and take over.

  • Chat ID → conversation with a contact
  • Transport → inbound webhook and outbound send
  • onEnd → write the turn, then send
  • useChat state → the database, because there is no client
  • Human handoff → a shared inbox reading the same stored thread
14

Failure and recovery: what belongs in the database

Persist the accepted user turn before requesting the model. Mark a turn complete only after its final validated response has been committed. Keep generation and external actions, such as sending a text, as separate durable steps: a saved answer is not proof that the message reached the customer.

Swipe across the table to see every column.

Suggested application behavior beyond the SDK stream callback.
FailureDurable stateRecovery
Browser disconnectsPending turn and reserved assistant IDConsume the stream where the runtime permits; load the committed result later.
Worker dies mid-generationPending turn remains visibleUse an explicit recovery policy; do not silently mark it complete.
Database save failsNo completed-turn recordSurface the save failure and reconcile the generation result.
Same request is retriedUnique request key and original inputReturn the stored result or pending status; reject changed input.
Two requests target one chatA serialization or version checkQueue or reject the conflict instead of overwriting newer history.
SMS/iMessage send times outSeparate outbound action recordReconcile delivery without regenerating and resending blindly.

Suggested application behavior beyond the SDK stream callback.

15

A checklist before you ship

Run these five tests against a staging deployment. Each one corresponds to a failure that is invisible in local development and obvious in production.

These checks cover common failures; also verify ownership, concurrent writes, database outages, and runtime shutdown behavior for your actual deployment.

  • Reload mid-stream: history is complete after the refresh
  • Close the tab mid-stream: the full response is stored
  • Double-submit: no duplicate messages appear
  • Deploy mid-conversation: the next turn still loads and validates
  • Remove a tool, then reopen an old chat: it degrades instead of crashing
Frequently asked questions

Quick answers

Where should AI SDK messages be saved — client or server?+

On the server, in the stream’s onEnd callback. A client-side save never runs when the tab closes mid-response, which is exactly when you most need the message written.

Why do my assistant messages duplicate after a reload?+

Common causes include mismatched IDs, duplicate model runs, and concurrent saves. Reserve the assistant ID under a durable request key, reuse it through generateMessageId, serialize writes per chat, and reconcile retried requests before generating again.

What does consumeStream do?+

It consumes the server-side stream independently of the browser. Completion still depends on the hosting runtime staying alive; persist pending turns and plan for process timeouts or crashes.

Do I need to validate stored messages?+

Yes. Persisted messages are input written by older code, and validateUIMessages checks them against your current tools and schemas before they re-enter the model. Preserve the original records, then repair or quarantine invalid data without replaying malformed tool state.

Primary sources

Read the documentation.

Ready to build?

Send your first blue bubble with Miss Blue.

Explore the iMessage API