supastack
v1.1.0
Published
Type-safe TanStack DB collections and TanStack Query options for Supabase tables, views, and RPC functions
Downloads
75
Maintainers
Readme
supastack
Type-safe TanStack DB collections and TanStack Query options for Supabase tables, views, and RPC functions.
Feed your Supabase-generated Database type to createSupabaseCollections and get:
- Tables — CRUD-enabled TanStack DB collections with optimistic mutations
- Views — read-only collections
- RPC — query options factories for
useQuery/useSuspenseQuery
All fully typed end-to-end from your database.types.ts.
Install
npm install supastack
# or
pnpm add supastackPeer dependencies
pnpm add @supabase/supabase-js @tanstack/db @tanstack/query-core @tanstack/query-db-collectionFor React, also add the TanStack bindings:
pnpm add @tanstack/react-db @tanstack/react-queryQuick start
1. Generate your Supabase types
supabase gen types typescript --local > src/database.types.ts2. Create collections
import { createClient } from '@supabase/supabase-js'
import { QueryClient } from '@tanstack/query-core'
import { createSupabaseCollections } from 'supastack'
import type { Database } from './database.types'
const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_ANON_KEY)
const queryClient = new QueryClient()
export const db = createSupabaseCollections<Database>(supabase, queryClient, {
tables: {
todos: { keyColumn: 'id' },
users: { keyColumn: 'id' },
},
views: {
active_users: { keyColumn: 'id' },
},
})3. Use in React
import { useLiveQuery } from '@tanstack/react-db'
import { useQuery } from '@tanstack/react-query'
import { db } from './db'
// Live query — re-renders when data changes
function TodoList() {
const { data: todos } = useLiveQuery((q) =>
q.from({ todos: db.tables.todos })
.where(({ todos }) => eq(todos.completed, false))
.orderBy(({ todos }) => todos.created_at, 'desc'),
)
return todos?.map((todo) => <div key={todo.id}>{todo.title}</div>)
}
// Mutations — optimistic by default
function AddTodo() {
const handleAdd = () => {
db.tables.todos.insert({
id: crypto.randomUUID(),
title: 'New todo',
completed: false,
user_id: currentUserId,
})
}
return <button onClick={handleAdd}>Add</button>
}
// RPC — returns query options for useQuery
function SearchResults({ query }: { query: string }) {
const { data } = useQuery({
...db.rpc.search_todos({ query }),
enabled: query.length > 0,
})
return data?.map((r) => <div key={r.id}>{r.title}</div>)
}Configuration
Table config
createSupabaseCollections<Database>(supabase, queryClient, {
tables: {
todos: {
// Required: column used as the collection key
keyColumn: 'id',
// Sync mode: 'eager' (default) loads all rows upfront,
// 'on-demand' fetches only rows matching the current query
syncMode: 'eager',
// Delay initial sync (e.g., until the user is authenticated)
startSync: true,
// Column selection passed to Supabase's .select()
select: 'id, title, completed',
// Which mutation operations to enable (default: all three)
// Use [] for read-only table collections
operations: ['insert', 'update', 'delete'],
// Automatic index creation for where expressions
autoIndex: 'eager', // 'off' (default) or 'eager'
defaultIndexType: BasicIndex,
// TanStack Query options
staleTime: 30_000,
refetchInterval: 60_000,
enabled: true,
retry: 3,
retryDelay: 1000,
gcTime: 300_000,
// Pagination tuning
pageSize: 1000, // rows per page for auto-pagination
inArrayChunkSize: 200, // max items per IN() before chunking
// Schema validation/transformation (Zod, Valibot, ArkType, etc.)
schemas: {
row: todoRowSchema, // transform fetched rows
insert: todoInsertSchema, // validate before insert
update: todoUpdateSchema, // validate before update (receives partial)
},
},
},
})View config
Same as table config but without insert/update schemas or operations. Views are read-only.
{
views: {
active_users: {
keyColumn: 'id',
staleTime: 60_000,
schemas: { row: activeUserSchema },
},
},
}RPC config
Per-function schemas and query options for RPC calls:
{
rpcs: {
search_todos: {
schemas: {
args: searchArgsSchema, // validate args before the network call
returns: searchResultSchema, // validate/transform the response
},
staleTime: 10_000,
retry: 3,
gcTime: 60_000,
},
},
}RPCs without a config entry work exactly the same — zero-config by default, opt-in depth when you need it.
wrapOptions
Hook to wrap collection options before createCollection. Use for persistence:
{
wrapOptions: (options) => persistedCollectionOptions(options),
}Schema transforms
Supabase returns date/time columns as strings. Use a schema to transform them:
import { z } from 'zod'
const todoRowSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
created_at: z.string().transform((s) => new Date(s)),
})
const db = createSupabaseCollections<Database>(supabase, queryClient, {
tables: {
todos: {
keyColumn: 'id',
schemas: { row: todoRowSchema },
},
},
})
// db.tables.todos now has created_at: Date instead of stringAny library implementing the Standard Schema protocol works (Zod, Valibot, ArkType, etc.).
Preserving types with defineConfig
TypeScript widens schema types when config is stored in a variable. Use defineConfig to preserve literal types:
import { createSupabaseCollections, defineConfig } from 'supastack'
const define = defineConfig<Database>()
export const config = define({
tables: {
todos: {
keyColumn: 'id',
schemas: { row: todoRowSchema },
},
},
})
// Schema output types are preserved
const db = createSupabaseCollections<Database>(supabase, queryClient, config)On-demand collections
For large tables, use syncMode: 'on-demand' to fetch only rows matching the current query. TanStack DB pushes predicates down and supastack translates them to PostgREST filters.
const db = createSupabaseCollections<Database>(supabase, queryClient, {
tables: {
logs: { keyColumn: 'id', syncMode: 'on-demand' },
},
})Operator mapping
| TanStack DB | PostgREST |
|---|---|
| eq | .eq() |
| gt, gte, lt, lte | .gt(), .gte(), .lt(), .lte() |
| inArray | .in() |
| like, ilike | .like(), .ilike() |
| isNull, isUndefined | .is(field, null) |
| and(a, b) | chained filters |
| or(a, b) | .or() |
| not(expr) | .not() |
| orderBy | .order() |
| limit / offset | .limit() / .range() |
JSON columns
Multi-segment field paths are automatically converted to PostgREST arrow notation:
// data.address.city -> data->address->>city
.where(({ row }) => eq(row.data.address.city, 'NYC'))Safeguards
- Predicateless guard — on-demand collections throw if queried without a
where,limit, or cursor to prevent accidentally fetching the entire table. - Auto-pagination — both eager and on-demand modes paginate via
.range()to avoid Supabase's default 1,000-row limit. - IN() chunking — large
inArrayfilters are split into parallel HTTP requests (default chunk size: 200) to avoid URL length limits.
RPC
db.rpc returns query options objects. Pass them to useQuery, useSuspenseQuery, or call queryFn directly:
// With useQuery
const { data } = useQuery(db.rpc.search_todos({ query: 'hello' }))
// With additional options
const { data } = useQuery({
...db.rpc.search_todos({ query }),
enabled: query.length > 0,
})
// Per-call overrides (when rpcs config is set)
const opts = db.rpc.search_todos({ query }, { staleTime: 5_000 })
// No-arg functions
const { data: time } = useQuery(db.rpc.get_server_time())
// Direct call
const result = await db.rpc.search_todos({ query: 'hello' }).queryFn()Advanced exports
For building custom integrations on top of supastack, prefer the smallest stable boundary that owns the behavior you need:
| Export | Boundary | Notes |
| --- | --- | --- |
| createRelationReader | Preferred read boundary | Executes Supabase reads for tables or views, including pagination, IN chunking, and on-demand safeguards. It does not expose query-plan internals. |
| executeQuery | One-shot read helper | Runs the same reader behavior for callers that do not need to keep a reader instance. |
| createMutationHandlers | Table mutation boundary | Builds TanStack DB mutation callbacks for insert/update/delete and schema validation. It does not create collections or read data. |
| createRpcProxy | RPC query-options boundary | Builds lazy RPC query option factories. It does not execute through a QueryClient. |
| applyLoadSubsetOptions | Low-level expression translation | Applies TanStack DB filters/order/windowing to a Supabase builder. It does not execute, paginate, chunk large IN predicates, or enforce on-demand guards. |
| createQueryFn | Compatibility adapter | Adapts the relation reader to a TanStack query function shape. Use only when you specifically need that queryFn boundary. |
| fetchTableData | Deprecated compatibility wrapper | Retained for the current major version. Use executeQuery or createRelationReader for new code. |
The current major version keeps the existing advanced exports available. Legacy helpers are compatibility wrappers so internals can keep moving behind the stable boundaries.
import {
// Main API
createSupabaseCollections,
defineConfig,
// Relation reader — preferred read boundary for custom integrations
createRelationReader,
// Query pipeline — compatibility helpers for custom queryFns
createQueryFn,
executeQuery,
// Mutation handlers — build custom mutation logic
createMutationHandlers,
// RPC proxy — build a standalone RPC layer
createRpcProxy,
// Expression translation — apply TanStack DB filters to Supabase queries
applyLoadSubsetOptions,
// Legacy (use executeQuery instead)
fetchTableData,
} from 'supastack'// Types
import type {
TableConfig,
ViewConfig,
TableSchemas,
QueryOptions,
SupabaseCollectionsConfig,
RpcQueryOptions,
RpcConfig,
QueryFn,
QueryPipelineConfig,
FetchTableDataOptions,
MutationHandlerConfig,
MutationHandlers,
RelationReader,
RelationReaderConfig,
SupabaseRelationClient,
} from 'supastack'Troubleshooting
If you see type errors about Collection not being assignable, you likely have duplicate versions of @tanstack/db. Run pnpm dedupe to fix it.
