Open lib/db/schema.ts in Vercel's ai-chatbot template and look at line 42:
export const message = pgTable("Message_v2", {
The _v2 is a scar. When ai 5 changed the message shape, migrating the existing table in place was harder than adding a new one beside it, so the template added Message_v2 and Vote_v2, backfilled, and eventually deleted the originals. The old tables are gone now. The names still carry the version number, permanently, in the schema every new project forks.
I have done that migration by hand. I did not want to do it again.
The 25 lines everyone writes
Persisting a chat with the AI SDK is not hard, exactly. It is just fiddly in a way that produces the same file in every project:
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,
});
Four things in there are load-bearing and easy to get wrong.
Drop generateMessageId and your rows arrive with empty ids. Skip the known set and every reload appends the same user message again. Forget the persisted guard and a stream that ends twice writes the reply twice.
The fourth one cost me an evening. I registered only onEnd, which is ai 7's callback name. On ai 6 the name is onFinish, and 6 does not warn you that nothing is listening - it just streams a perfect answer to the user and writes nothing to the database. Passing both names is the fix, and a CI job that runs the whole suite against the older major is how I found out.
Why the obvious answers did not fit
The AI SDK's own persistence guide is good, and it is a pattern rather than a package: you copy it into each app and maintain your copy. That is fine until the copy is in four apps.
assistant-ui cloud and Convex both solve persistence properly, and both solve it by holding the data. If the conversation has to live in the database you already run - for joins, for compliance, or because your Postgres is right there - that is the wrong trade.
Forking the template gets you an app, not a dependency. You inherit its auth stack, its blob storage and its choices.
Two tables and a typed store
So: ai-sdk-threads. Threads and messages in your Postgres or SQLite. Two tables you re-export, one store you build once:
// db/schema.ts - plain drizzle objects, so your own migration tooling picks them up
export { messages, threads } from "ai-sdk-threads/drizzle";
// lib/threads.ts - from the drizzle instance you already have
export const store = createThreadStore(db);
After npm install ai-sdk-threads drizzle-orm and a migration, the whole chat route becomes this:
export const POST = chatHandler({
store,
execute: ({ modelMessages }) =>
streamText({
model: openai("gpt-5"),
messages: modelMessages,
}),
});
It loads the thread, stores the incoming message once, streams the answer, and stores the reply - with both callback names registered, so it does not silently do nothing on ai 6.
Message parts go into the database as the SDK produced them, so what comes back out is what useChat rendered: tool calls with their outputs, reasoning parts, the lot. There is no lossy projection in the middle to debug at 2am.
One thing that route is still missing before production is authorization. Thread ids come from the client, so an authorize callback is the difference between "my chat app works" and "anyone who guesses an id can read that conversation".
The part the SDK has no answer for
Regenerate an answer in ChatGPT, Claude or v0 and the old answer does not disappear - you can page back to it with a little ‹ 2/3 › control. Edit an earlier question and the original stays, on its own branch.
Almost every app built on the AI SDK throws that away, because keeping it is a storage problem wearing a UI costume. Asking for it is vercel/ai#2929, open since September 2024. Elsewhere in the ecosystem, assistant-ui tracks branches in its client runtime and @ably/ai-transport keeps a tree on its realtime channels - both leave the durable copy to you, which is the part this owns.
The model is unglamorous. Every message stores a parentId; every thread stores an activeLeafId. A regenerated answer is a second child of the same parent rather than an overwrite, and the live conversation is the walk from the active leaf back to the root:
// Regenerate: point the leaf where a fresh answer belongs, then stream into it.
await store.regenerateFrom(threadId, assistantMessageId);
// The ‹ 2/3 › control, straight from the store.
const { siblings, index } = await store.siblingsOf(threadId, assistantMessageId);
// Switch which path is live; everything downstream comes back with it.
await store.setActiveLeaf(threadId, siblings[index - 1].id);
Nothing is ever deleted. getTree hands you every row if you want to draw the whole shape; loadMessages hands you only the live path, which is what useChat wants.
A real Postgres, in the page
Rather than ask you to install anything to believe that, the docs site compiles Postgres to WebAssembly and runs it in the tab. Regenerate an answer, switch between siblings, and watch the rows and the query log change as it happens - it is the published store writing real rows, not a mock: ai-sdk-threads.nixrajput.com/en/playground.
One layer down
Three details for anyone deciding whether to trust this with their data.
Loading a thread is 2 queries whether it holds one message or five hundred. The root-to-leaf path is walked in memory rather than with a recursive CTE, and listThreads is one query per page - a page 50,000 rows deep measured 1.13x the first page on Postgres 16 over 100,000 threads, with the harness in the repo so you can re-run it. Every operation's query count is pinned by a test, so an N+1 fails CI rather than surfacing as a slow page later.
Every row is stamped with sdk_version. That is the whole point of the exercise: when the next major lands, a migrate CLI can tell you what needs touching instead of you guessing, and there is an importer for the Vercel template's tables if you started there.
ai 6 and 7 are both gated in CI, running the full suite of 198 tests against each. That is not thoroughness for its own sake - it is the job that caught the onEnd/onFinish bug above.
What it deliberately is not
It stores conversations; it does not retrieve over them. No vector search, no summarisation, no agent orchestration - different problem, different library.
It is not chat UI. ai-elements and assistant-ui own that layer, and this stores what they render.
ai 4 and older are unsupported: 5 was a rewrite and the supported range is >=6 <8.
And there is no throughput benchmark, because persistence is not a speed story. The numbers above are query counts and test counts, which is what I can actually defend.
Try it
MIT, no runtime dependencies in the core, 1.04 kB minified and gzipped, and everything it does today stays free.
Docs and the playground: ai-sdk-threads.nixrajput.com (there is an llms.txt if you are an agent reading this).
The thing I would most like to hear: where does this storage model break for conversations bigger than mine? I have measured 100,000 threads and 500-message paths. If yours are bigger and it falls over, that is the issue I want.
Top comments (5)
@nixrajput, keeping
parentIdandactiveLeafIdmakes regeneration honest, and gating both SDK majors catches callback drift before users see it. The remaining coupling is the “store parts exactly as produced” choice: it preserves fidelity but lets provider- or SDK-specific part shapes leak into durable history. Would you keep an immutable raw envelope plus a versioned normalized projection, so migrations can rebuild the live path without rewriting original evidence?Thank you, this is a sharp framing - and it's most of what the storage already does.
Reads already work that way.
loadMessagesnever hands back stored bytes directly: each row'spartspass throughmigrateParts(parts, sdkVersion)on the way out, keyed by the major that wrote that particular row. The row itself is never touched by a read, so the stored payload is the immutable envelope and whatuseChatreceives is a projection of it. A thread written across three majors comes back coherent with nothing rewritten.What's missing from your description is that the projection is computed rather than materialised. That costs nothing today -
migratePartsis a pass-through for 5, 6 and 7, whose part payloads are byte-identical, which I checked rather than assumed (test/fixtures/parts-v5andparts-v6). Caching an identity function would be pure overhead. If a real transform ever lands, the first thing I'd want isn't the stored projection but a second version stamp:sdkVersionrecords what wrote the row, and nothing records which projector produced the output.Your point lands properly on the migrate CLI, which does write
partsback in place when a migration changes them. It has never actually rewritten one - the transform is an identity, so it only restampssdkVersion- but the destructive path is there. Making it additive rather than in-place is a fair change to want, and it's on the list.On the leak itself, storing parts as produced is deliberate and the containment sits at the edges rather than in the schema:
convertToUIMessagesthrows onfile,imageand provider-specific content rather than guessing a shape for it, so a lossy row can't enter quietly.Thanks for this - exactly the kind of question I was hoping the piece would draw.
The
onEndvsonFinishstory is the strongest argument in the piece: ai 6 doesn't warn that nothing is listening, it streams a perfect answer and writes nothing to the database. Silent persistence failure behind a happy user is precisely the class of bug that survives review, because the visible behaviour is correct. Registering both names and gating CI on two majors is what makes it unmissable — that's the pattern I'd take even without the library.Two things I'd push on.
parentIdplusactiveLeafIdwith nothing ever deleted is the right model for regeneration, but the growth is a real cost: have you looked at how a long-lived thread behaves after many regenerations, or is there a pruning policy for branches off the live path? And you're right to flag authorization as the missing production piece — thread ids from the client means a guessed id reads someone else's tree. Does theauthorizecallback guard the load or each mutation? Those two questions are what every integrator hits in the first hour.Thank you, this is a genuinely useful comment. I'm glad that part landed, because it's the bit I'd most want someone to carry away even if they never install anything.
It's worth being precise about where the type system covers you there, though, because it very nearly does. ai 6 has no
onEndat all - onlyonFinish, which ai 7 then deprecated in favour ofonEnd. So if you pass your options as an inline object literal, TypeScript does catch it on ai 6:TS2353: 'onEnd' does not exist in type .... Excess property checking saves you.Where it stops saving you is more ordinary than you'd hope. If the options are built as a variable first, excess property checks no longer apply and it passes straight through. If you develop and typecheck against ai 7 while someone runs your code on ai 6, the types never see the problem. Same if the callback is wired up somewhere untyped. The runtime is silent in all three - the stream completes, the user is happy, nothing reaches the database.
That middle case is really why the CI gate does the work rather than just adding confidence. For a package supporting a version range, the types you compile against are whichever version happens to be installed, so actually running the suite against the older major is the only thing that proves anything. Passing both names costs nothing, and only one of them ever fires.
On to your two questions, both of which are fair.
Branch growth. You're right that it's a real cost, and the honest answer turned out worse than the one I'd have given you from memory, so I went and measured it.
The mechanism isn't what the "2 queries" claim implies.
loadMessagesruns one query for the thread and one that fetches every row in the thread, then walks root-to-leaf in memory. The query count stays constant; the payload doesn't. Abandoned branches ride along on every read.A 50-message thread, measured:
So regenerating is cheap - one stranded row each - and editing early is what actually hurts. Rewriting question 5 of a 25-turn thread strands all 38 messages below it in a single click, and the thread carries that weight on every load afterwards. A conversation someone edits repeatedly near the top degrades much faster than one they regenerate at the bottom.
There was no pruning policy, which was a gap. One is landing in the next release:
pruneBranches(threadId)deletes everything the active leaf can't reach and hands back what it removed, so you can log or archive first. It's deliberately explicit rather than automatic - keeping abandoned branches is the whole point of the tree, so discarding them should never happen as a side effect of a regeneration.Authorization. Good question, and the honest answer is that it guards the request rather than the store:
chatHandlerrunsauthorizeonce at the top of the POST and answers 403. That single gate covers the whole turn - load, append, stream, persist - since a turn is one logical operation on one thread.resumeHandlerruns it on GET and DELETE separately, and answers 204 rather than 403, so someone probing can't tell "not your thread" from "nothing to resume".chatHandlercorrectly doesn't cover them.You're right that both of these are what an integrator hits in the first hour, which is a good argument for them being much more visible than they are.
Thanks again for pushing on both - easily the most useful response the piece has had, and both are going into the docs because of it.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.