AI · Open source
TanStack AI RAG
A third-party RAG kit for TanStack AI 1.x: token-aware text and Markdown chunking with overlap, ingest through TanStack AI embed() with atomic re-ingest, in-memory and Postgres pgvector stores (pg, postgres.js, PGlite, Drizzle), metadata-filtered retrieval, optional rerank through rerank(), and a server tool for chat() whose citation numbers continue across searches in one run.
What it solves
TanStack AI 1.x has embed(), rerank() and server tools, but vector storage stays in your app.
A chat that answers from your own documents still needs a chunker, an ingest step that batches embeddings and replaces a document's old chunks, a vector table with indexes, a filtered similarity query, and a tool whose results the model can cite and the UI can show as sources. Without a kit, each app writes that glue itself.
createRag({ embedding, store }) returns ingest(), retrieve(), formatContext(), tool() and remove(). ingest() chunks each document (plain text or Markdown), embeds the chunks through TanStack AI's embed() in batches of 64, then swaps the document's old chunks for the new ones; on pgvector the delete and insert run in one transaction, so a failed insert keeps the old version. Each chunk after the first starts with up to overlap tokens from the end of the previous one, cut at a word boundary. Chunk sizes are counted with a pluggable token counter; the default is an estimate of 4 characters per token, not a tokenizer. retrieve() embeds the question, runs a cosine-similarity search with an optional metadata filter and minScore, optionally reranks the candidates with TanStack AI's rerank(), and numbers the results 1, 2, 3. rag.tool() wraps retrieval as a server tool for chat({ tools }): the model sends a search query and gets back { query, results: [{ citation, id, source, url?, headings?, content, score }] }, which the client also receives as the tool call's output. Within one chat() call, citation numbers continue across searches and a chunk returned again keeps its number. Stores: memoryVectorStore() (exact search, not persisted) and pgvectorStore() for Postgres with pgvector through pg, postgres.js, PGlite or Drizzle, with a migrate() that creates the table, a GIN metadata index and an HNSW cosine index. Any other database works through the VectorStore interface: three methods plus an optional replaceDocuments() for atomic re-ingest. The built code imports only embed, rerank and toolDefinition from the @tanstack/ai main entry and has no runtime dependencies. 123 tests, including Postgres + pgvector in Docker through pg, postgres.js and Drizzle, plus a TanStack Start app, all tested with local mocks and no live AI provider. A third-party package, not made or endorsed by TanStack.
- chunkText() and chunkMarkdown(): chunks of at most maxTokens (default 512), broken at paragraphs, then lines, then sentences, then words, then a hard cut; character offsets and token count on every chunk
- Overlap: each chunk after the first starts with the longest end of the previous chunk that fits in overlap tokens (default the smaller of 64 and maxTokens / 4), starting at a word boundary, also when paragraphs are longer than overlap
- Token counting through countTokens: the default estimateTokens is Math.ceil(length / 4), an estimate; pass a real tokenizer such as js-tiktoken for exact sizes
- Markdown: chunks never cross or overlap across an ATX heading, each records its heading path, and fenced code blocks (``` or ~~~) stay whole when they fit
- ingest(): embeds chunks with any TanStack AI embedding adapter through embed(), 64 per call by default, with the document title and heading path prepended to the embedded text; re-ingesting an id replaces all of its chunks, in one transaction on pgvector
- pgvectorStore(): pg Pool or Client, postgres.js, PGlite or Drizzle; migrate() creates the vector extension, the table, a document_id index, a GIN jsonb_path_ops metadata index and an HNSW vector_cosine_ops index (or none)
- memoryVectorStore(): exact cosine search in process, nothing persisted; or implement the VectorStore interface (three methods, plus optional replaceDocuments) for any other database
- Metadata filters: every key must equal its scalar value; on Postgres the filter is one JSON parameter matched with jsonb @>, never interpolated into SQL
- retrieve(): topK (default 5), minScore on cosine similarity, citation numbers 1..n in result order, and source taken from the title, else the url, else the document id
- Optional rerank through TanStack AI rerank(): topK * 4 vector candidates by default, results in the reranker's order with rerankScore
- rag.tool(): a server tool for chat(), named search_knowledge_base by default, with a query-only input schema, a fixed server-side filter, the chunk id on each result, scores rounded to 3 decimals, and citation numbers that continue across searches in one chat() call (citations: 'call' numbers each search from 1); citationInstructions is a matching system prompt
- Peer dependency @tanstack/ai ^1.0.0 (tested with 1.0.0); imports only embed, rerank and toolDefinition from its main entry; no runtime dependencies; ESM only; Node 20 or later; 7.4 kB gzipped
- 123 tests including Docker pgvector through pg, postgres.js and Drizzle; tested with local mocks, no live AI provider
Install
Requires TanStack AI 1.x.
npm install @amjadiqbal/tanstack-ai-rag @tanstack/ai
Usage
// src/server/rag.ts
import pg from 'pg'
import { openaiEmbedding } from '@tanstack/ai-openai'
import { createRag, pgvectorStore } from '@amjadiqbal/tanstack-ai-rag'
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
const store = pgvectorStore({ db: pool, dimensions: 1536 })
await store.migrate()
export const rag = createRag({ embedding: openaiEmbedding('text-embedding-3-small'), store })
// src/routes/api/chat.ts
import { createFileRoute } from '@tanstack/react-router'
import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { citationInstructions } from '@amjadiqbal/tanstack-ai-rag'
import { rag } from '../../server/rag'
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const { messages, threadId } = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-4o-mini'),
threadId,
messages,
systemPrompts: [citationInstructions],
tools: [rag.tool({ description: 'Search the Acme Widgets docs.' })],
})
return toServerSentEventsResponse(stream)
},
},
},
})
Setup
Install the package with @tanstack/ai, its only peer dependency (^1.0.0, tested with 1.0.0). It has no runtime dependencies of its own, ships as an ES module only and needs Node 20 or later. The built code imports embed, rerank and toolDefinition from the @tanstack/ai main entry and nothing else, so it uses only public TanStack AI APIs. It makes no network calls of its own: embedding and reranking go through the TanStack AI adapters you pass in, and the database client is yours. Use it on the server; rag.tool() is a TanStack AI server tool.
- 1 npm install @amjadiqbal/tanstack-ai-rag @tanstack/ai
- 2 Add a TanStack AI embedding adapter, for example npm install @tanstack/ai-openai, and for reranking a rerank adapter such as @tanstack/ai-cohere.
- 3 Add the driver for your store: pg for a pg Pool or Client; postgres for postgres.js; drizzle-orm plus pg for Drizzle (only drizzle-orm/node-postgres is tested); @electric-sql/pglite and @electric-sql/pglite-pgvector for PGlite. memoryVectorStore() needs nothing.
- 4 For Postgres, run a server with the pgvector extension, for example docker run -d -e POSTGRES_PASSWORD=postgres -p 5432:5432 pgvector/pgvector:pg17, then create pgvectorStore({ db, dimensions }) with your embedding size and call store.migrate() once on start (it is idempotent).
- 5 Create the pipeline with createRag({ embedding, store }), ingest your documents, and pass rag.tool() to chat({ tools }) with citationInstructions in systemPrompts.
Chunking
chunkText() and chunkMarkdown() take maxTokens (default 512), overlap (default the smaller of 64 and maxTokens / 4, rounded down) and countTokens. Sizes are measured with countTokens; the default, estimateTokens, returns Math.ceil(text.length / 4), an estimate rather than a tokenizer, so pass a real one (js-tiktoken is tested) when chunks must fit a hard model limit. A maxTokens that is not a positive integer, or an overlap that is not an integer from 0 to maxTokens - 1, throws a RangeError. Every chunk has content (trimmed), index, tokens (counted on content), start and end, where source.slice(start, end) equals content.
- Text that fits maxTokens is kept as one piece. A larger span is split at blank lines; any piece still too large is split at line breaks, then after ., ! or ?, then at whitespace, and a piece with no usable break is cut to the longest length that fits.
- Pieces are packed greedily into chunks of at most maxTokens.
- Overlap: each chunk after the first starts with the longest end of the previous chunk that is at most overlap tokens and still leaves the new chunk within maxTokens. It starts at a word boundary, or at any character when the text has no whitespace. It is smaller, or zero, when the next piece already fills most of maxTokens. Ten paragraphs of about 150 estimated tokens, with the defaults, gave 5 chunks where each neighbouring pair shares 63 tokens; 0.1.3 gave 4 chunks with no overlap.
- The overlap ignores structure: it can start mid-sentence, and in Markdown it can start inside a fenced code block, so a chunk may begin with the end of a code block without its opening fence.
- Markdown: ATX headings (# to ######, up to 3 spaces of indent, trailing # removed) start a section, and chunks never cross a section or overlap across one. Each chunk gets headings, the heading path outermost first; the heading line itself is the first block of the section, so it also appears in the chunk text.
- Inside a section, blocks are separated by blank lines. A fenced block opened with ``` or ~~~ runs until a fence of the same character and at least the same length, # lines inside it are not headings, and it stays one piece when it fits maxTokens; otherwise it is split like text.
- A heading with no body of its own (followed straight by another heading, or at the end of the document) gives no chunk; its title stays in the path of the sections under it. Text before the first heading is chunked without headings.
- Setext headings (underlined with === or ---) and headings inside HTML blocks are treated as text.
import { chunkMarkdown } from '@amjadiqbal/tanstack-ai-rag'
import { getEncoding } from 'js-tiktoken'
const enc = getEncoding('cl100k_base')
const chunks = chunkMarkdown(markdown, { maxTokens: 400, overlap: 40, countTokens: (t) => enc.encode(t).length })
// [{ content, index, tokens, start, end, headings: ['Guide', 'Install'] }, ...]
Ingest and re-ingest
rag.ingest() takes one document or an array of { id, content, format?, title?, url?, metadata? }. It first checks the ids: a missing id or the same id twice in one call throws a TypeError before anything is embedded or stored. Each document is chunked with chunkMarkdown() when format is markdown, else chunkText(). The text embedded for a chunk is the document title and heading path joined with " > ", a blank line, then the chunk; only the chunk text is stored. The chunks of every document in the call are embedded in order through embed(), batchSize (default 64, at least 1) per call, with dimensions when set and documentModelOptions as modelOptions. If the adapter returns a different number of vectors than inputs, ingest() throws.
- After embedding, ingest() replaces all stored chunks of every document in the call with the new ones. Re-ingesting an id therefore replaces it completely, including when the new version has fewer chunks. There is no content hashing: re-ingesting unchanged content embeds it again.
- When the store has replaceDocuments(), ingest() calls it once for the whole call; both built-in stores have it. pgvectorStore runs the delete and every 500-row insert in one transaction, so a failed insert rolls back, the error is rethrown and the old chunks stay. memoryVectorStore copies the new records first, then deletes and inserts with no await in between, so a query sees the old chunks or the new ones.
- A custom store without replaceDocuments() gets deleteDocuments() then upsert(), and a failure between the two loses the document's old chunks.
- An embedding error leaves the previous chunks in place in every store, because nothing is written until all chunks are embedded.
- Chunk ids are <documentId>#<chunkIndex>. Metadata on each chunk is the document metadata plus title, url, headings and chunkIndex, which overwrite keys of the same name.
- A document with empty or blank content produces no chunks, and its previous chunks are deleted. ingest([]) returns { documents: 0, chunks: 0 } without calling the adapter.
- ingest() returns { documents, chunks }. rag.remove(id or ids) deletes every chunk of those documents.
- Models that want different input types for documents and queries, such as Cohere search_document and search_query, get them through documentModelOptions and queryModelOptions.
await rag.ingest([
{
id: 'docs/install.md', // ingesting this id again replaces its chunks
title: 'Install guide', // citation source (else url, else id)
url: 'https://example.com/docs/install',
content: installMarkdown,
format: 'markdown', // default 'text'
metadata: { product: 'widgets' }, // stored on every chunk, usable in filters
},
])
await rag.remove('docs/install.md')
Postgres with pgvector
pgvectorStore({ db, table, dimensions, index }) works with anything that has query(text, params) resolving to { rows } (a pg Pool or Client, PGlite), a postgres.js sql through sql.unsafe(text, params), or a Drizzle database, whose driver client it reads from $client. It imports none of them. table defaults to rag_chunks and may be schema-qualified (kb.chunks). dimensions is used only by migrate(), for the vector(n) column; the store does not check vector length, so a vector of the wrong size gets Postgres's own error.
- migrate() needs dimensions (else TypeError, nothing created) and runs CREATE EXTENSION IF NOT EXISTS vector, then CREATE TABLE IF NOT EXISTS with id text primary key, document_id text, content text, metadata jsonb (default {}) and embedding vector(dimensions), all NOT NULL. Safe to run on every start.
- Indexes, created if missing: a b-tree on document_id, a GIN index on metadata with jsonb_path_ops, and an HNSW index on embedding with vector_cosine_ops. index: 'none' skips the HNSW index for exact search. Index names are the table name (dot replaced by _) plus _document_id_idx, _metadata_idx and _embedding_idx.
- HNSW on the vector type needs pgvector 0.5 or later and at most 2,000 dimensions. For larger embeddings, such as 3,072 for text-embedding-3-large, use index: 'none', request fewer dimensions from the model, or create your own index.
- Search orders by the <=> cosine distance operator and returns 1 - distance as score, with LIMIT topK. With the HNSW index this is an approximate nearest-neighbour search.
- upsert() writes 500 rows per INSERT ... ON CONFLICT (id) DO UPDATE statement. deleteDocuments() runs DELETE ... WHERE document_id = ANY($1::text[]).
- replaceDocuments() runs that delete and the inserts in one transaction, opened per driver: sql.begin() on postgres.js, transaction() on PGlite, and on a pg Pool one client checked out with connect() for BEGIN, the statements and COMMIT (ROLLBACK on error), then released. Drizzle uses the same path as its $client. Anything else, such as a single pg Client, gets BEGIN and COMMIT sent on that connection, so other queries sent on the same client during an ingest become part of the transaction.
- Injection safety: the table name must match letters, digits and _ (optionally schema.name, each part at most 63 characters) and is double-quoted; ids, content, metadata, vectors, topK and the filter are all bound parameters. Vectors with NaN or Infinity throw a TypeError before any SQL runs.
- You can skip migrate() and create the table yourself with the same column names.
import { PGlite } from '@electric-sql/pglite'
import { vector } from '@electric-sql/pglite-pgvector'
import { pgvectorStore } from '@amjadiqbal/tanstack-ai-rag'
const store = pgvectorStore({
db: new PGlite({ extensions: { vector } }),
table: 'kb.chunks',
dimensions: 1536,
})
await store.migrate()
In-memory and custom stores
memoryVectorStore() keeps records in a Map and scores every record with exact cosine similarity on each query. Nothing is persisted; it has size and clear(). Vectors of different lengths throw a RangeError on query, and a zero vector scores 0. It suits tests, demos and small corpora rebuilt on start.
- A VectorStore is three methods: upsert(records) replaces records by id, query({ vector, topK, filter }) returns up to topK matches best first with score as cosine similarity, and deleteDocuments(documentIds) removes every chunk of those documents.
- An optional fourth, replaceDocuments(documentIds, records), deletes and stores as one atomic step; ingest() uses it when present.
- No Pinecone, Upstash Vector or Qdrant store ships in 0.1.4.
import type { VectorStore } from '@amjadiqbal/tanstack-ai-rag'
const myStore: VectorStore = {
async upsert(records) {}, // { id, documentId, content, metadata, vector }[]
async query({ vector, topK, filter }) { return [] }, // { id, documentId, content, metadata, score }[]
async deleteDocuments(documentIds) {},
async replaceDocuments(documentIds, records) {}, // optional: delete + insert as one atomic step
}
Retrieval, filters and citations
rag.retrieve(question, { topK, minScore, filter, abortSignal }) embeds the question through embed() with queryModelOptions, queries the store, and returns RagResult objects: the store's id, documentId, content, metadata and score, plus citation, source, url, headings and, with a reranker, rerankScore. topK comes from the call, else createRag's topK, else 5. minScore, when set, drops matches whose cosine similarity is below it.
- filter is typed as an object of scalar values (string, number, boolean or null), and every key must match. For those values both stores agree: the value must be equal and of the same type (1 does not match '1'), and null matches a key stored as null but not a missing key.
- memoryVectorStore compares with ===. pgvectorStore sends the whole filter as one JSON parameter and adds WHERE metadata @> $3::text::jsonb, jsonb containment served by the GIN index. If an array or object is cast into a filter, the stores differ: in memory it never matches, while on Postgres { tags: ['a'] } matches a chunk tagged ['a', 'b'].
- There are no ranges, IN or OR. title, url and chunkIndex are stored in metadata and can be filtered.
- retrieve() numbers its results 1, 2, 3 in the final order (after reranking), starting at 1 on every call. The run-wide numbering below belongs to rag.tool().
- source is the document title, else its url, else its id. formatContext(results) renders "[n] source (Heading > Sub)" followed by the chunk text, with blank lines between results, for retrieving before chat() and passing the block in systemPrompts.
- abortSignal is passed to the rerank request only. TanStack AI 1.0 embed() takes no abort signal and the store calls take none, so the embedding step and the store query cannot be cancelled.
const results = await rag.retrieve(question, { topK: 4, filter: { product: 'widgets' } })
const stream = chat({
adapter: openaiText('gpt-4o-mini'),
threadId,
messages,
systemPrompts: [citationInstructions, rag.formatContext(results)],
})
Optional rerank
With createRag({ rerank: { adapter, candidates, modelOptions } }), retrieve() asks the store for more matches than it returns: candidates, default topK * 4, and never fewer than topK. minScore is applied to these vector matches first. The chunk texts of the remaining matches go to TanStack AI's rerank() with the query, topN: topK, modelOptions and the abort signal; the results take the reranker's order and indexes, keep the vector score as score and add rerankScore. When no vector match is left, the reranker is not called and the result is empty.
- The reranker sees only the chunk text, not the title or heading path that was embedded with it.
- The tool reports rerankScore as its score when a reranker is set.
import { cohereRerank } from '@tanstack/ai-cohere'
const rag = createRag({
embedding,
store,
topK: 4,
rerank: { adapter: cohereRerank('rerank-v3.5'), candidates: 20 },
})
The retrieval tool for chat()
rag.tool({ name, description, topK, minScore, filter, citations }) returns toolDefinition({ name, description, inputSchema }).server(...), a TanStack AI server tool. The default name is search_knowledge_base. The input schema is a JSON Schema object with one required string property, query, so the model chooses only the search text; topK, minScore and filter are fixed on the server, which makes filter suitable for a tenant or product id. Write a description that says what the knowledge base holds, since the model uses it to decide when to search.
- Output: { query, results: [{ citation, id, source, url?, headings?, content, score }] }, where id is the chunk id (<documentId>#<chunkIndex>) and score is rounded to 3 decimals (the rerank score when a reranker is set). The client receives the same object as the tool call's output, so the UI can list the sources.
- citations: 'run' (the default): within one chat() call, the first search is numbered 1, 2, ...; a later search gives new chunks the next numbers and a chunk already returned keeps its number. Results stay in relevance order, so a later search can list [4] before [2]. citations: 'call', or a call with no abort signal in the tool context (outside chat()), numbers every search from 1.
- How a run is scoped: the numbers live in a WeakMap keyed by the abort signal in the tool context. TanStack AI 1.0 builds a new signal for every chat() call, even when you pass the same abortController, so a tool created once at module level does not share numbers between concurrent requests, and the map entry is dropped with the run.
- The map belongs to the createRag() instance, so every tool made from one rag shares the count within a run. Tools from two different createRag() instances in one chat() call each start at 1, and so does a tool with citations: 'call'.
- Numbering lasts one chat() call. The next request, such as the next user message, starts at [1] again, so a [1] in an earlier answer in the history can name a different chunk; use the id to tell them apart.
- An empty, blank or missing query returns { query: '', results: [] } without embedding anything.
- The run's abort signal from the tool context is passed to the rerank request.
- citationInstructions is a system prompt telling the model to answer from the search results, cite each claim as [1] or [2], and say when the results do not contain the answer.
import type { RagToolOutput } from '@amjadiqbal/tanstack-ai-rag'
// In a useChat() message from @tanstack/ai-react
m.parts.map((p, i) =>
p.type === 'tool-call' && p.name === 'search_knowledge_base' && p.output ? (
<ol key={i}>
{(p.output as RagToolOutput).results.map((r) => (
<li key={r.id}>[{r.citation}] {r.url ? <a href={r.url}>{r.source}</a> : r.source}</li>
))}
</ol>
) : null,
)
Limits
These are gaps in 0.1.4, read from the code at the tag.
- The default token count is an estimate (4 characters per token); pass a tokenizer for exact sizes.
- Overlap is cut at a word boundary without regard to sentences or code fences, and shrinks to zero when the next piece nearly fills maxTokens.
- Markdown: ATX headings only; setext headings and headings in HTML blocks are text.
- Re-ingest is atomic only in stores with replaceDocuments() (both built-in stores have it). On a single pg Client the transaction shares the connection with any other query sent on it.
- Unchanged content is embedded again on every ingest.
- Filters are equality on scalar metadata values only.
- Citation numbers continue only within one chat() call and one createRag() instance; retrieve() and formatContext() number each call from 1.
- The embedding step and the store query cannot be aborted; only rerank receives the signal.
- The default HNSW index is limited to 2,000 dimensions.
- No Pinecone, Upstash Vector or Qdrant stores.
Compatibility and testing
Requires @tanstack/ai ^1.0.0 and was tested with 1.0.0, @tanstack/ai-openai 1.0.0 and @tanstack/ai-cohere 1.0.0. It uses only public @tanstack/ai APIs: the published dist imports embed, rerank and toolDefinition from the main entry. 123 tests at the v0.1.4 tag with the Docker Postgres suites enabled (89 run without Docker), including Postgres 17 + pgvector in Docker through pg, postgres.js and Drizzle. It was also tested in a TanStack Start app with @tanstack/ai-react against local mocks (no live AI provider), running the packed 0.1.4 tarball, and installed fresh from npm (0.1.4 from the registry, empty cache) into a new app, where overlap, the re-ingest rollback and run-wide citations were seen working. Everything was tested with local mocks: no live embedding, chat or rerank API was called and no API key was used, so retrieval quality with real embeddings is not measured. 0.1.4 changes the overlap rule, makes re-ingest atomic and numbers tool citations per run. A third-party package, not made or endorsed by TanStack.
- Unit tests (vitest): chunk sizes with the estimator and with js-tiktoken, overlap on long paragraphs (every neighbouring pair overlaps by at most overlap tokens), offsets, coverage, Markdown heading paths, fenced code with backticks and tildes, CRLF; ingest, re-ingest, failed-embedding rollback, remove, filters, minScore, batching, model options, formatContext, rerank order, scores and abort signal, and the tool's output, chunk id, name, description, filter, empty query and citation numbering across calls in one run, in a new run, with citations: 'call' and without a signal.
- Store conformance, the same checks on each store: memoryVectorStore, pgvectorStore on PGlite, and pgvectorStore on pgvector/pgvector:pg17 in Docker through a pg Pool, postgres.js and Drizzle (drizzle-orm/node-postgres): nearest-first order, scores, metadata filters, null and number-vs-string filters, nested JSON metadata, upsert-replace, delete by document, a 1,200-row batch, and replaceDocuments, including a failure in the second 500-row batch that leaves the old chunks in place; plus idempotent migrate() and the HNSW index.
- Separately: array and object filters on both stores, a filter with SQL in its key and value returning nothing on PGlite, a failed re-ingest through createRag on PGlite keeping the old chunks, and the memory store swapping in one step.
- Integration with the real @tanstack/ai-openai and @tanstack/ai-cohere adapters over HTTP against local mocks: embeddings into PGlite and Docker Postgres, chat() with openaiText (Responses API) and openaiChatCompletions calling rag.tool() and answering with a citation, two searches in one chat() run numbered [1], [2], then [3], context passed through systemPrompts, and Cohere rerank reordering results.
- TanStack Start app with @tanstack/ai-react useChat and the packed 0.1.4 tarball, against local mocks: chunks that start with the end of the previous one, a failing re-ingest keeping all old chunks, two searches in one answer numbered on from the first with repeated chunks keeping their numbers, the next question starting at [1], and rerank and remove still working.
- Fresh install: a new app installed ^0.1.4 from the npm registry with an empty cache and no lock file, then ran against Docker pgvector and local mocks: chunks starting with the end of the previous one, a failing re-ingest rolling back (still 9 chunks), and citation numbers continuing across searches in one run, as recorded in the package's progress log.
- Not tested: live embedding, chat and rerank APIs, embedding adapters other than OpenAI's, TanStack AI versions after 1.0.0, hosted Postgres services, a single pg Client as the store's db (the transaction path without a pool), Drizzle on its postgres.js or PGlite drivers, and Vue, Solid or Svelte clients.
Hire me
Need this built into your app, or something custom?
I wrote TanStack AI RAG. I can wire it into your TanStack app, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.
Support
Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.
More TanStack packages
All packages
Query Key Kit
Declare your TanStack Query v5 keys once as a typed tree, get queryOptions()-shaped objects back, and invalidate or update whole branches of the cache with typed helpers.
Start SEO Kit
Typed SEO helpers for TanStack Start: one head() call for title, canonical, Open Graph, Twitter, robots and hreflang tags, a sitemap and robots.txt served from server routes, typed JSON-LD with Product schema, and an optional Open Graph image route.
Table URL State
Keep a TanStack Table v9 table's sorting, column filters, global filter, pagination and column visibility in the URL search params, in both directions, and export the current view to CSV or, from a separate entry point, to XLSX.
AI SDK Bridge
A third-party migration stepping stone: run Vercel AI SDK provider models (@ai-sdk/* and community providers, provider spec v2, v3 and v4) as TanStack AI 1.0 adapters for chat, embeddings, transcription, speech, image generation and rerank, then swap each one for its native TanStack AI adapter.
shopify-app-tanstack-start
Build embedded Shopify apps on TanStack Start with the server API of Shopify's React Router package: session tokens and token exchange, webhook and app proxy verification, the mandatory compliance webhooks and the Billing API, as Start middleware, server functions and server routes.
Table Server Query
Turn TanStack Table v9 sorting, column filters, global search and pagination into server-side queries for Drizzle (Postgres, SQLite), Prisma, Kysely and Supabase/PostgREST, checked against a column allow-list and returned as rows plus a total count.
firebase-auth-tanstack-start
Firebase Authentication for TanStack Start with HttpOnly Firebase session cookies: the browser signs in with the Firebase JS SDK, and the server verifies the session cookie or a bearer ID token and puts a typed user with custom claims on the context for server rendering, server functions, server routes and beforeLoad guards.