Chat persistence for the Vercel AI SDK

Threads, message trees, branching and resumable streams in your own database. Zero runtime dependencies.

$npm i ai-sdk-threads
v0.1.6 on npm

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.

by hand25 lines
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.

ai-sdk-threads8 lines
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.

setActiveLeafactiveLeafId = a4

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

RequirementValue
Node.js>=20
ai>=6 <8, gated on both in CI
Module formatESM only
DatabasePostgres, 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

The AI SDK persistence guide

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

assistant-ui cloud

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

Convex

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

Vercel's ai-chatbot template

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

Your database. Your rows. No service to sign up for.

$npm i ai-sdk-threads