npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

ai-sdk-threads

The AI SDK gives you useChat. This gives you somewhere to put it.

CI npm Stars Contributors License: MIT Last commit Issues PRs

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, parts

Features

  • One-line chat route - chatHandler replaces the load/store/stream/store boilerplate every AI SDK app writes by hand.
  • Postgres or SQLite - the same ThreadStore contract 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 - resumableChat ships the POST/GET/DELETE trio, so a reload mid-answer picks the stream back up.
  • UIMessage-native - parts and metadata stored verbatim (jsonb on 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 - listThreads pages by cursor, not OFFSET, so page 400 costs what page 1 does.
  • convertToUIMessages - the ModelMessage to UIMessage direction the SDK still does not ship as of ai 7 (vercel/ai#7180, open).
  • Zero runtime dependencies - ai, drizzle-orm and resumable-stream are 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.45 for the ./drizzle and ./sqlite adapters, and resumable-stream ^2.2 for ./resume - both optional peers, so you install only what you use
  • ESM only, with no CJS build

Install

npm install ai-sdk-threads drizzle-orm

ai-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 migrate

This 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 path

loadMessages 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 useChat and 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_v2 table

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 ai 4 or older. ai 5 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