ai-sdk-threads
v0.1.5
Published
Chat thread and message persistence for the Vercel AI SDK - UIMessage-native schema, branching, resumable streams, Postgres and SQLite. Your database, your data.
Maintainers
Readme
ai-sdk-threads
The AI SDK gives you useChat. This gives you somewhere to put it.
Threads · message trees · branching · resumable streams · Postgres or SQLite · zero runtime dependencies Loading a thread is 2 queries whether it holds 1 message or 500 - the root-to-leaf path is walked in memory, not with a recursive CTE - and listThreads is one query per page - every cursor page of a full walk, asserted by that same test - which held at 1.13x the first page 50,000 rows deep on Postgres 16 over 100,000 threads (the harness is in the repo). Every operation's query count is pinned by a test, so an N+1 fails CI. Also checkable: 198 tests, 30 running the identical contract against both databases; ai 6 and 7 both gated in CI, which caught the handler storing nothing on the older major; no Node globals in src/, enforced by a second typecheck. See the runs.
Documentation · Getting started · API reference · Playground
AI agents / LLMs: the documentation is machine-readable at llms.txt, or as one blob at llms-full.txt.
Contents
Before and after
The AI SDK's own persistence guide has you hand-roll the choreography in your route: load the thread, filter what is new, store it before streaming, generate an id, register the finish callback under both of its names, store the reply.
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 result = streamText({
model: openai("gpt-5"),
messages: await convertToModelMessages([...existing, ...fresh]),
});
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,
});With chatHandler:
export const POST = chatHandler({
store,
execute: ({ modelMessages }) =>
streamText({ model: openai("gpt-5"), messages: modelMessages }),
});25 lines to 8 - and the short one also does authorization, branching, and the truncated-reply handling the long one does not attempt. Both samples are published in the docs and typechecked against this package on every build, so neither can drift into a strawman.
Overview
Every AI SDK chat app ends up writing the same two tables, the same append-on-finish hook, and the same load-on-mount query - and usually flattens UIMessage.parts into a content string on the way in, which quietly loses tool calls, reasoning, and files.
ai-sdk-threads is those two tables and a small typed store over them. Message parts go into the database as JSON exactly as the SDK produced them, so what comes back out is what useChat rendered - tool invocations and their outputs included. It is your database and your rows; this package owns no service and phones nothing home.
useChat ──── POST /api/chat ────▶ chatHandler ────▶ ThreadStore ────▶ your database
▲ │ │
└───────── UIMessage stream ────────┘ ├── ai_sdk_threads active_leaf_id
└── ai_sdk_messages parent_id, partsFeatures
- One-line chat route -
chatHandlerreplaces the load/store/stream/store boilerplate every AI SDK app writes by hand. - Postgres or SQLite - the same
ThreadStorecontract over either, verified by one parity suite run against both. - Branching - edit or regenerate a message and the old version survives as a sibling, the way ChatGPT does it (vercel/ai#2929, open since 2024).
- Resumable streams -
resumableChatships the POST/GET/DELETE trio, so a reload mid-answer picks the stream back up. UIMessage-native -partsandmetadatastored verbatim (jsonbon Postgres, text JSON on SQLite), never flattened to a content string.- A drizzle/Postgres adapter - works with node-postgres, postgres.js, Neon, Vercel Postgres, or PGlite.
- Your migrations - the tables are exported as drizzle objects and land in your own schema and migration history.
- Keyset pagination -
listThreadspages by cursor, notOFFSET, so page 400 costs what page 1 does. convertToUIMessages- theModelMessagetoUIMessagedirection the SDK still does not ship as ofai7 (vercel/ai#7180, open).- Zero runtime dependencies -
ai,drizzle-ormandresumable-streamare peers, the last two optional. Install only what you use. - Edge-safe core - no Node globals anywhere in
src/, enforced by a second typecheck in CI.
Getting started
Prerequisites
- Node.js
>=20 ai>=6 <8- CI runs the whole suite against both 7.0.x and the 6.x floor- Postgres with a drizzle instance pointed at it, or SQLite via
./sqlite drizzle-orm^0.45for the./drizzleand./sqliteadapters, andresumable-stream^2.2for./resume- both optional peers, so you install only what you use- ESM only, with no CJS build
Install
npm install ai-sdk-threads drizzle-ormai-threads and ai-sdk-persistence on npm are name reservations only - they contain no code and are not maintained. Install ai-sdk-threads.
Add the tables to your schema
The two tables are plain drizzle pgTable objects. Re-export them from your schema file so your existing migration tooling picks them up:
// db/schema.ts
export { messages, threads } from "ai-sdk-threads/drizzle";Then generate and run a migration the way you already do, for example with drizzle-kit:
npx drizzle-kit generate
npx drizzle-kit migrateThis creates ai_sdk_threads and ai_sdk_messages. The ai_sdk_ prefix keeps them from colliding with your application tables.
Quickstart
Create the store once and share it:
// lib/threads.ts
import { createThreadStore } from "ai-sdk-threads/drizzle";
import { db } from "./db";
export const store = createThreadStore(db);Then your whole chat route is the handler. It loads the thread, stores the incoming message before streaming, streams the answer, and stores the reply:
// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { chatHandler } from "ai-sdk-threads/handler";
import { store } from "@/lib/threads";
export const POST = chatHandler({
store,
execute: ({ modelMessages }) =>
streamText({ model: openai("gpt-5"), messages: modelMessages }),
});Load the history when the page renders and hand it straight to useChat:
// app/chat/[id]/page.tsx
import { notFound } from "next/navigation";
import { currentUserId } from "@/lib/auth";
import { store } from "@/lib/threads";
import { Chat } from "./chat";
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const thread = await store.getThread(id);
// The id comes from the URL, so the page needs its own ownership check: `authorize` guards
// chatHandler, not this render. 404 rather than 403, so a stranger cannot tell an existing
// thread from a missing one. loadMessages then throws for an id with no thread yet.
if (thread && thread.userId !== (await currentUserId())) notFound();
const messages = thread ? await store.loadMessages(id) : [];
return <Chat id={id} initialMessages={messages} />;
}Before you deploy, add authorization in both places. Thread ids come from the client, so chatHandler needs an authorize callback and any page that renders a thread needs the same ownership check - authorize does not run on a server render. See Securing a thread.
How branching is stored
Every message row points at its parent, and the thread records which leaf is live. Regenerating does not overwrite - it adds a sibling.
ai_sdk_threads.active_leaf_id = "a2"
m1 user "Explain closures, briefly."
├── a1 assistant "A function bundled with the variables…" sibling, still stored
└── a2 assistant "Think of a backpack the function carries…" live pathloadMessages returns the root-to-leaf path, so the conversation reads as one thread while every abandoned branch stays queryable. Point setActiveLeaf at a1 and the older answer is live again, with whatever replies hung off it.
You can run this against a real Postgres in your browser - the playground compiles the database to WebAssembly and drives this package's published build, showing the call it made and the rows it produced.
Documentation
Full documentation lives at ai-sdk-threads.nixrajput.com.
| | |
| ------------------------------------- | ----------------------------------------------------------------------- |
| Getting started | Install, schema, and a persisted useChat conversation |
| chatHandler | Every option, what it does in order, and securing a thread |
| resumableChat | The POST/GET/DELETE trio, and the Redis-backed context |
| The store | Thread and message methods, keyset pagination, orderPath |
| Branching | Regenerate, edit-and-fork, sibling navigation |
| convertToUIMessages | The ModelMessage to UIMessage direction |
| SQLite | The same contract, and the three constraints that are not optional |
| Schema | Both tables, every column, and why timestamps are millisecond precision |
| Migrating | sdk_version on every row, and the migrate CLI |
| Importing | Bringing over Vercel's ai-chatbot template tables |
| Playground | Branching in a real Postgres running in your browser |
Is this for you
Good fit if you…
- build on
useChatand are about to write the persistence layer by hand - want branching - regenerate and edit-and-fork - stored rather than faked in component state
- need the conversation in your database, for compliance, for joins, or because you already run Postgres
- expect to survive the AI SDK's next major without inventing a
Message_v2table
Skip it if you…
- want a managed service with hosted sync, search and analytics today. assistant-ui and Convex do that properly; this is the self-hosted core, and everything it does today stays free and MIT - the store, the route handlers,
convertToUIMessages, branching, resumable streams, both adapters and the migration tooling. - need vector or semantic memory. Different problem: this stores conversations, it does not retrieve over them.
- are on
ai4 or older.ai5 was a rewrite, and the supported range is>=6 <8. - want chat UI components. ai-elements and assistant-ui own that layer; this stores what they render.
Compared to
Nothing below is a like-for-like competitor, which is rather the point.
| | Scope | Where the data lives | Branching stored | Cost |
| -------------------------------------------------- | ----------------------------------------------- | ----------------------- | ---------------- | --------------------- |
| ai-sdk-threads | Threads, messages, branching, resumable streams | Your Postgres or SQLite | Yes | MIT core, self-hosted |
| The AI SDK's persistence guide | A pattern to copy per app | Yours | No | Free, hand-maintained |
| assistant-ui cloud | UI plus hosted persistence | Their infrastructure | Runtime-side | Per active user |
| Convex | A whole reactive backend | Their platform | Yours to model | Per usage |
| Vercel's ai-chatbot template | An app to fork | Yours | No | Free, fork-and-own |
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.
Not affiliated with Vercel. "AI SDK" refers to the ai package.
FAQ
Why not just follow the SDK's persistence guide?
You can, and for one simple app you probably should. The reasons people stop: it silently stores an empty id if you forget generateMessageId, it stores nothing at all on ai 6 if you register only onEnd, it duplicates rows if you forget to filter what the transport reposts, and it has no answer for branching. Each of those is a real bug with a test in this repo.
Do I need Redis? Only for resumable streams across more than one instance. The default stream context is in-process, which is genuinely enough in development and on a single server, and documented as insufficient beyond that rather than quietly failing.
What happens when ai 8 breaks the message shape?
Every row records the major that wrote it in sdk_version, and real captured payloads from ai 5, 6 and 7 are committed as fixtures, so the suite reports the day a format actually changes. As of ai 7 stored parts are byte-identical across all three majors, so there is nothing to convert yet - migrateParts is a pass-through and says so, rather than pretending to work.
Is SQLite a second-class adapter?
No - the parity suite runs the identical contract against both, so behaviour that holds on Postgres but not SQLite fails the build. It does carry three hard constraints, all documented: an async driver, no bare :memory:, and PRAGMA foreign_keys = ON.
Can I use it without the handler? Yes - the store is the product and works alone. The docs show the hand-written route and name the three things that are easy to get wrong.
Contributing
Contributions are welcome. Fork, branch, and open a PR - see CONTRIBUTING.md for the checks a PR has to pass. Bugs and ideas go to Issues; questions to Discussions; vulnerabilities follow SECURITY.md.
Documentation changes belong in nixrajput/ai-sdk-threads-docs, which owns the site's content. When a public API changes here, the matching docs change is a separate PR there.
Contributors
Thanks to everyone who has contributed to ai-sdk-threads.
License
Licensed under the MIT license - see LICENSE.
Support the project
ai-sdk-threads is MIT licensed and free to use, always. If it saves you writing those two tables again, sponsorship is welcome.
Connect
Nikhil Rajput
