Chat persistence for the Vercel AI SDK
Threads, message trees, branching and resumable streams in your own database. Zero runtime dependencies.
The route
Same behaviour, one call
Both of these are in the docs and both typecheck against the published package on every build. The left one is what the persistence pattern asks you to maintain per app.
const { id, messages } = await req.json();
const existing = await store.loadMessages(id);
const known = new Set(existing.map((m) => m.id));
const fresh = messages.filter((m) => m.role === "user" && !known.has(m.id));
if (fresh.length > 0) await store.appendMessages(id, fresh);
const history = [...existing, ...fresh];
const result = streamText({
model: openai("gpt-5"),
messages: await convertToModelMessages(history),
});
let persisted = false;
const persist = async ({ responseMessage }) => {
if (persisted || responseMessage.parts.length === 0) return;
persisted = true;
await store.appendMessages(id, [responseMessage]);
};
return result.toUIMessageStreamResponse({
generateMessageId: generateId,
onEnd: persist,
onFinish: persist,
});Miss generateMessageId and replies store with an empty id. Register only onEnd and nothing persists on ai 6. Forget to filter and every turn duplicates rows.
export const POST = chatHandler({
store,
execute: ({ modelMessages }) =>
streamText({
model: openai("gpt-5"),
messages: modelMessages,
}),
});Plus authorization, branching, and the truncated-reply handling the left pane does not attempt.
Branching
Regenerate, and the old answer is still there
Every message points at its parent, so a regenerated reply is a sibling rather than an overwrite - and each sibling keeps the turns that followed it. Switch the active leaf and the whole conversation below it comes back. Nothing is ever deleted.
getTree - every row, nothing removed
- m1userExplain closures, briefly.
- a1assistantA closure is a function bundled with the variables it was defined alongside.sibling
- m2userShow me one.
- a3assistantfunction counter() { let n = 0; return () => ++n; }
- a2assistantThink of a backpack: the function carries the variables it grew up with wherever it goes.
- m3userWhy does that matter?
- a4assistantBecause the variable outlives the call that created it.
7 rows stored - 0 deleted
loadMessages - the live path only
- userExplain closures, briefly.
- assistantThink of a backpack: the function carries the variables it grew up with wherever it goes.
- userWhy does that matter?
- assistantBecause the variable outlives the call that created it.
4 of 7 rows on this path
- siblingsOf
- Every variant of one message, oldest first, with the index of the live one.
- forkAt
- Edit a question and the old version and its replies stay, on their own branch.
- sdk_version
- Stamped per row, so an AI SDK major bump is a migration rather than a guess.
An illustration of the stored shape - the buttons are siblingsOf plus setActiveLeaf, and the AI SDK itself still has no answer for the tree (vercel/ai#2929, open since 2024). Run it against a real database in your browser.
Coverage
What ai-sdk-threads gives you
- One-line chat route
- chatHandler replaces the load, store, stream, store boilerplate every AI SDK app writes by hand.
- Branching
- Edit or regenerate and the old version survives as a sibling, the way ChatGPT does it.
- Resumable streams
- resumableChat ships the POST, GET and DELETE trio, so a reload mid-answer picks the stream back up.
- Postgres or SQLite
- One ThreadStore contract over either, verified by a parity suite run against both.
- UIMessage-native
- Parts and metadata stored verbatim, never flattened to a content string that loses tool calls.
- Zero runtime dependencies
- ai, drizzle-orm and resumable-stream are peers, the last two optional. Install only what you use.
- Keyset pagination
- listThreads pages by cursor rather than OFFSET: one query per page, and a page 50,000 rows deep measured 1.13x the first.
- Edge-safe core
- No Node globals anywhere in src, enforced by a second typecheck that compiles without Node types.
Requirements
What your project needs
| Requirement | Value |
|---|---|
| Node.js | >=20 |
| ai | >=6 <8, gated on both in CI |
| Module format | ESM only |
| Database | Postgres, or SQLite via ./sqlite |
| drizzle-orm | ^0.45, optional |
| resumable-stream | ^2.2, optional |
The core carries no runtime dependencies - the two adapter peers are optional, so you install only the database you use, and resumable-stream only if you resume streams. Six subpath exports, all typed:
- .
- ./drizzle
- ./handler
- ./resume
- ./sqlite
- ./cli
Alternatives
Compared to the alternatives
marks the tool ahead on that row, whichever tool it is
ai-sdk-threads
- Scope
- Threads, messages, branching, resumable streams
- Where the data lives
- Leads: Your Postgres or SQLite
- Branching stored
- Leads: Yes - the old answer stays as a sibling
- AI SDK major migration
- Leads: sdk_version on every row, plus a migrate CLI
- Chat UI components
- No - ai-elements and assistant-ui own that layer
- Hosted sync and search
- No
- Cost
- MIT core, self-hosted
- Scope
- A pattern to copy into each app
- Where the data lives
- Yours
- Branching stored
- No
- AI SDK major migration
- No
- Chat UI components
- -
- Hosted sync and search
- No
- Cost
- Free, hand-maintained
- Scope
- UI plus hosted persistence
- Where the data lives
- Their infrastructure
- Branching stored
- Tracked in the runtime; storage undocumented
- AI SDK major migration
- Their concern rather than yours
- Chat UI components
- Leads: A full component library
- Hosted sync and search
- Leads: Sync, search and analytics
- Cost
- Per active user
- Scope
- A whole reactive backend
- Where the data lives
- Their platform
- Branching stored
- Yours to model
- AI SDK major migration
- Yours to write
- Chat UI components
- No
- Hosted sync and search
- Leads: Reactive sync, search and functions
- Cost
- Per usage
- Scope
- An app to fork
- Where the data lives
- Yours
- Branching stored
- No
- AI SDK major migration
- A new Message_v2 table, backfilled by hand
- Chat UI components
- Leads: A whole app, already styled
- Hosted sync and search
- No
- Cost
- Free, fork-and-own
Nothing here is a like-for-like competitor, which is rather the point. If you want the managed experience, assistant-ui and Convex are good at it. Everything this package does today is MIT and stays that way; only a future managed layer could ever be otherwise. This exists for the case where the conversation has to stay in a database you control, and where regenerate and edit need to survive a reload. If you started from the Vercel template, its tables import straight across.
The project
By the numbers
- 245
- downloads a month
- 1.04 kB
- minified + gzipped
- 113 kB
- unpacked size
- 1
- GitHub star
Contributor