<!-- https://missblue.dev/blog/vercel-ai-sdk-persistence -->

[All articles](https://missblue.dev/blog) 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, 2026 14 minute read

In this guide [Why persistence is its own problem](#section-1) [The four moving parts](#section-2) [Create the chat before the first message](#section-3) [Load history on the server, hand it to the client](#section-4) [Send the last message, not the whole thread](#section-5) [Generate message IDs on the server](#section-6) [Save when the stream ends, on the server](#section-7) [Handle the client that walks away](#section-8) [Validate what you load back](#section-9) [Choosing storage](#section-10) [Idempotency and duplicate writes](#section-11) [The same pattern outside the browser](#section-12) [A checklist before you ship](#section-13)

Build with Miss Blue **Turn 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](https://missblue.dev/signup)  [Explore the API](https://missblue.dev/imessage-api)

Build with Miss Blue **Turn 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](https://missblue.dev/signup)  [Explore the API](https://missblue.dev/imessage-api)

01

## 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.

02

## 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

03

## 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() {
  const id = await createChat();
  redirect(`/chat/${id}`);
}
```

04

## 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;
  const messages = await loadChat(id);
  return <Chat id={id} initialMessages={messages} />;
}
```

05

## 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 { UIMessage, useChat } from '@ai-sdk/react';
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 })
}
```

06

## Generate message IDs on the server

The client generates an ID for the user message so React can key it immediately. The assistant message needs an ID too, and it must be the same ID that ends up in the database. createIdGenerator produces prefixed, fixed-size IDs, and passing one as generateMessageId makes the server the authority for assistant message 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.

07

## 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 there — not in a client-side effect — means the write happens even if the browser is closed mid-response.

createUIMessageStreamResponse wraps the result for the route to return. The whole persistence contract is these few lines.

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

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

  const previousMessages = await loadChat(id);
  const messages = [...previousMessages, message];

  const result = streamText({
    model: anthropic('claude-sonnet-5'),
    messages: await convertToModelMessages(messages),
  });

  result.consumeStream();

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({
      stream: result.stream,
      originalMessages: messages,
      generateMessageId: createIdGenerator({ prefix: 'msg', size: 16 }),
      onEnd: ({ messages }) => {
        saveChat({ chatId: id, messages });
      },
    }),
  });
}
```

08

## 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.

This is one line and it is the difference between a chat history with holes in it and one without. It costs you the tokens for a response nobody read, which is almost always the right trade.

09

## 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 — drop or repair the offending message rather than failing the whole conversation, 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
-   Drop or repair individual bad messages instead of failing the thread
-   Never trust stored tool results as though they were fresh

10

## 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

11

## 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.

Make the save an upsert keyed on message ID. Because the IDs are server-generated and stable, the same turn produces the same key, and the second write overwrites rather than appends. This is the same discipline any webhook consumer needs, for the same reason.

12

## 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

13

## 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.

If all five pass, the persistence layer is sound and you can move on to the more interesting problems — context windows, tool results, and how much history to replay.

-   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?+

Almost always mismatched IDs. Pass a generateMessageId built with createIdGenerator so the ID used during streaming is the same one written to storage, and make the save an upsert keyed on message ID.

What does consumeStream do?+

It drains the stream so generation completes even after the client disconnects, which lets onEnd fire and the full response be persisted rather than truncated.

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. Repair or drop bad messages rather than failing the whole conversation.

Primary sources

## Read the documentation.

-   [AI SDK: Chatbot Message Persistence](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence)
-   [Miss Blue: iMessage API and conversation events](https://missblue.dev/imessage-api)

Explore the platform

## Turn the research into a working conversation.

[For developers

### iMessage API

Build two-way blue-bubble conversations into your product or agent.

Explore](https://missblue.dev/imessage-api) [For teams

### Message Center

Use a complete shared conversation platform without writing code.

Explore](https://missblue.dev/features/message-center) [For industry teams

### iMessage use cases

See practical playbooks for real estate, plumbing, HVAC, roofing, and home services.

Explore](https://missblue.dev/use-cases) [Miss Blue Research

### iMessage vs SMS study

Read the beta observations, reported benchmarks, methodology, caveats, and pilot framework.

Explore](https://missblue.dev/imessage-response-rate-study)

Ready to build?

## Send your first blue bubble with Miss Blue.

[Explore the iMessage API](https://missblue.dev/imessage-api)
