Conversation Persistence & Integrations

Add Persistent Chat History to the Vercel AI SDK

July 10, 2026

Back to Blog

A Vercel AI SDK app streams responses and renders the chat UI, but it doesn’t keep the conversation. The messages live in browser state and in the request that’s currently running, so a reload or a redeploy leaves the user with an empty thread. Making chat history persistent means moving those messages somewhere durable, so they outlast the browser tab and the server process.

Doing that takes two things: saving each turn as it finishes, and loading the conversation back when the user returns. The catch specific to the AI SDK is the shape of a message. Each one isn’t a plain string but a set of structured parts (text, tool calls, reasoning), and the store has to keep those parts intact and return them in the same shape, or the reloaded conversation won’t replay correctly.

All of it comes down to a mapping between the two message formats, plus a save and a load. This post walks through a small useChat app wired to DialogueDB that already handles it: every turn is saved the moment the model finishes, and the full conversation reloads after a restart, so history survives a refresh, a redeploy, or a user coming back days later. Clone it, run it locally, and the same handful of lines drops straight into your own Route Handler. And because that history now lives in DialogueDB, the same conversation is instantly ready for semantic search and cross-session memory, with no second or third datastore to bolt on.

What persistent chat history adds to a Vercel AI SDK app

Once the messages live somewhere durable, you get more than a thread that survives a refresh. The example is a small Next.js reference with three moving parts: a Route Handler that streams and saves replies, a client that loads the conversation on mount, and the one file that maps between the two message formats.

How a turn round-trips

Saving a turn

BrowseruseChat
messages
Route HandleronFinish
saveMessages
DialogueDBstored history
refresh, redeploy, or cold start

Reloading the conversation

DialogueDBstored history
loadUIMessages
Route HandlervalidateUIMessages
messages
BrowseruseChat, rehydrated

A refresh or redeploy clears useChat's in-memory history. DialogueDB keeps it, so the reload path rebuilds the same conversation, tool calls and all.

Every turn is written to storage as it finishes, and on load the conversation is read back into useChat. A refresh or a redeploy clears the browser’s copy of the chat, and the reload path rebuilds it from storage, tool calls and all.

Because the messages live in DialogueDB rather than a flat table, the same data supports two more things:

How to save and reload chat history

There are two message shapes, and the integration is the mapping between them. A useChat message is { id, role, parts }. A stored message is { id, role, content }. Store the parts array as content on the way in, and on the way out parse the rows back with validateUIMessages, which Vercel’s own message persistence guide recommends. The parts stay structured throughout, so text, tool calls, and reasoning come back in the same shape they went in.

// persist.ts
import { validateUIMessages, type UIMessage } from 'ai'
import type { DialogueDB, MessageContent } from 'dialogue-db'

export function toStoredMessages(
  messages: UIMessage[],
): { role: string; content: MessageContent }[] {
  return messages.map((message) => ({
    role: message.role,
    content: message.parts,
  }))
}

export async function loadUIMessages(
  db: DialogueDB,
  id: string,
  namespace: string,
): Promise<UIMessage[]> {
  const dialogue = await db.getDialogue(id, { namespace })
  if (!dialogue) return []
  await dialogue.loadMessages({ order: 'asc' })
  return validateUIMessages({
    messages: dialogue.messages.map((m) => ({
      id: m.id,
      role: m.role,
      parts: m.content,
    })),
  })
}

The Route Handler wires that into a normal streaming response. It saves the incoming message, streams the model, and saves the reply when the turn finishes. See how each step maps to the code that runs it, hover any step to trace it:

app/api/chat/route.ts
import { streamText, convertToModelMessages }from "ai"
import { openai } from"@ai-sdk/openai"
import { DialogueDB } from"dialogue-db"
import{ toStoredMessages } from"@/lib/persist"
 
const db =new DialogueDB()
 
export async functionPOST(req: Request) {
const { messages, dialogueId, userId }= await req.json()
 
const dialogue =await db.getOrCreateDialogue({
id: dialogueId,
namespace: userId,
})
 
// persist the incoming user turn
await dialogue.saveMessages(
toStoredMessages([messages[messages.length - 1]]),
)
 
const result =streamText({
model: openai("gpt-4o"),
messages: awaitconvertToModelMessages(messages),
})
 
return result.toUIMessageStreamResponse({
originalMessages: messages,
onFinish: async({ messages: updated }) => {
// persist the new assistant message(s)
await dialogue.saveMessages(
toStoredMessages(updated.slice(messages.length)),
)
},
})
}

Hover a step to see the lines it maps to.

On load, the client reads the stored conversation and hands it to useChat through its messages prop, so the UI renders the history with no special-casing. To reproduce this in your own app, you drop those same two calls (the save when a turn finishes and the load on mount) into your Route Handler.

See the full Vercel AI SDK integration

An interactive walkthrough of the whole flow, from the client side to memory and semantic search.

See the integration

Why use a conversation database?

The example is wired to DialogueDB, the conversation database at the center of this whole pattern. One client stores the messages and, over that same data, adds memory, semantic search, threads, and per-user isolation, all under one namespace and one API key. The chat history you just persisted is already everything those capabilities run on.

That breadth from a single client is the real reason to reach for DialogueDB here. On most stacks these are three separate systems to run and keep in sync: one to persist messages, a second to hold memory that survives between sessions, and a third (usually a vector database) to search past turns by meaning. DialogueDB collapses all three into one client:

  • Messages, memory, and search share a namespace, so there are no user IDs to keep aligned across separate stores.
  • Message content is stored as structured parts, so tool calls and reasoning replay exactly.
  • Semantic search is built in, so there’s no separate vector database to run or keep in sync.
  • The client is HTTP-only with no connection pool, so it runs on the Vercel Edge runtime and standard serverless.

You also decide what gets remembered. There is no background process reading conversations and extracting facts; you call createMemory with the value you want to keep.

Postgres vs DialogueDB for chat history

You don’t strictly need DialogueDB to persist chat history, since a plain Postgres table works fine for the storage itself. The difference is how much you have to assemble yourself to match what the example already does out of the box.

You handlePostgres + pgvectorDialogueDB
Message schema and migrationsDesign and maintainProvided
Structured parts (tool calls)Serialize and rebuildStored as parts
Search across turnsAdd and sync a vector storeBuilt in
Loading a conversationWrite the querygetDialogue + loadMessages
Per-user isolationQuery code on every readnamespace on every call
Memory across sessionsA second store to buildSame client

Because DialogueDB is managed, it runs and scales the storage for you instead of you standing up a database and keeping it healthy. In exchange, messages, memory, and search all come from one integration instead of three, with no vector database or sync code to maintain. If you are weighing the storage options more broadly, How to Store AI Chat History covers the tradeoffs, and Conversation Persistence for TypeScript Agent Frameworks looks at the message-format problem across SDKs.

Frequently asked questions

Run the example

Clone the example, add your DialogueDB and OpenAI keys (OpenAI is just the model the example calls; the persistence pattern works with any AI SDK provider), and start it:

git clone https://github.com/dialoguedb/examples
cd examples/vercel-ai-sdk
npm install
cp .env.example .env
npm run dev

You will see turn one, a cold reload from DialogueDB, then turn two continued from the reloaded history. From there, the same mapping and save calls drop into a real Next.js Route Handler. The integration page walks through the client side and the full request flow, and the quickstart gets you an API key.

Ready to Build Better Conversations?

Get started with DialogueDB in minutes. Free tier included.

Get Your API Key