Comparison
Convex
How kitcn extends vanilla Convex.
Overview
kitcn extends vanilla Convex with tRPC-style APIs and deep TanStack Query integration. It's not a replacement - it builds on Convex's real-time database:
| Layer | Vanilla Convex | kitcn Adds |
|---|---|---|
| Server | query, mutation, action | Fluent builder, middleware, Zod |
| Client | useQuery, useMutation | TanStack Query, auth-aware skipping |
| SSR | preloadQuery() | prefetch(), caller, HydrateClient |
| Errors | ConvexError | CRPCError with HTTP codes |
Let's explore each layer.
ORM vs Vanilla Convex (ctx.orm vs ctx.db)
Vanilla Convex gives low-level document queries and writes. The ORM adds a relational builder surface plus runtime guardrails.
Read Query Surface
| Capability | Vanilla Convex (ctx.db) | ORM (ctx.orm) | Notes |
|---|---|---|---|
Relational query builder (findMany / findFirst / with) | No | Yes | Drizzle-style read surface |
findFirstOrThrow() | No | Yes | Throws when no row matches |
Object where filters | Manual query/filter code | Yes | Includes comparison/logical/string operators |
Callback where ((table, ops) => ...) | No equivalent | Yes | Drizzle-style callback helpers |
Explicit index anchoring (withIndex(name, range?)) | Manual low-level index API | Yes | Required for predicate where; available for index-first planning |
Predicate where with explicit .withIndex(...) | Manual only | Yes | Required for ops.predicate(...); no implicit full scan fallback |
Relation filters (where: { relation: ... }) | No | Yes | Nested relation predicates in reads |
Eager relation loading (with) | Manual multi-query | Yes | Includes nested relation loading |
Bounded scan controls (allowFullScan, maxScan) | Manual scan control | Yes | Guardrails for fallback paths and full-scan opt-in |
| Search index querying | Yes | Yes | ORM surface: findMany({ search }) |
| Vector index querying | Yes | Yes | ORM surface: findMany({ vectorSearch }) |
| Cursor pagination | Yes | Yes | ORM enforces typed cursor + limit usage |
| Offset pagination | Yes | Yes | Supported, but deep offsets are still expensive |
Projection/computed fields (columns, extras) | Manual mapping | Yes | Computed/projection work runs post-fetch (no SQL computed columns) |
Multi-field orderBy | Manual sort logic | Yes | First field drives cursor stability; secondary fields may sort post-fetch |
| Unsized non-paginated reads guardrail | No built-in ORM-style guardrail | Yes | Throws unless limit, cursor pagination, defaultLimit, or allowFullScan |
| Relation fan-out key cap | No built-in ORM-style guardrail | Yes | relationFanOutMaxKeys fail-fast guardrail |
findMany({ distinct }) | Manual dedup | No | Use select().distinct({ fields }) pipeline when needed |
Aggregate and Count
| Capability | Vanilla Convex (ctx.db) | ORM (ctx.orm) | Notes |
|---|---|---|---|
count() | Manual/custom | Yes | Unfiltered path uses native Convex count syscall |
count({ where }) | Manual/custom | Yes | Strict no-scan; requires matching aggregateIndex |
count({ select: { _all, field } }) | No helper | Yes | Field counts require .count(field) metric declaration |
count({ orderBy, skip, take, cursor }) | Manual/custom | Yes | Windowed count with ordering and pagination bounds |
aggregate({ _count/_sum/_avg/_min/_max }) | No helper | Yes | Prisma-style aggregate blocks |
| Scan fallback for aggregate/count | Manual implementation choice | No | ORM aggregate/count never fall back to collect/take scan paths |
| Allowed aggregate/count filter subset | Manual/custom | eq / in / isNull / range + AND + finite safe OR rewrite | Strict no-scan v1 subset |
OR in aggregate/count where | Manual/custom | Yes | Safe finite OR rewrite when each branch is index-plannable |
Unsupported aggregate/count filters (NOT / string / relation) | Manual/custom | Blocked | Deterministic unsupported-filter errors |
aggregate({ orderBy/take/skip/cursor }) | Manual/custom | Partial | orderBy/cursor supported; skip/take is _count-only in v1 |
Relation _count in with | No helper | Yes | with: { _count: { relation: true } } |
Relation _count nested relation filter | Manual/custom | No | Blocked in v1 (RELATION_COUNT_FILTER_UNSUPPORTED) |
Relation _count filtered through() relation | Manual/custom | Yes | Indexed lookups + no-scan-safe filter validation |
Mutation returning({ _count }) | No | Yes | Split selection + relation count loading on insert/update/delete |
groupBy({ by, _count/_sum/_avg/_min/_max }) | No | Yes | Finite-constrained by fields; having/orderBy/skip/take/cursor supported |
Window analytics (having / window functions) | No | No | Deferred |
Ranked access (rankIndex + rank()) | No | Yes | ORM-native rankIndex for leaderboards, random access, sorted pagination (no @convex-dev/aggregate dependency) |
| Aggregate read path overhead | Component calls often cross ctx.runQuery boundary | Direct aggregate/count read in current query | ORM avoids extra component read boundary for scalar aggregate/count paths |
Mutations, Constraints, and Delete Modes
| Capability | Vanilla Convex (ctx.db) | ORM (ctx.orm) | Notes |
|---|---|---|---|
Builder API (insert / update / delete) | No | Yes | Drizzle-style mutation builders |
.returning() | No | Yes | Returns affected rows (always array) |
Upserts (onConflictDoNothing / onConflictDoUpdate) | No helper | Yes | Runtime-enforced against unique constraints/indexes |
Write safety guard (update/delete require where) | No | Yes | Must use where or opt in with allowFullScan |
Paginated update/delete batches (.paginate) | Manual | Yes | Cursor-based write batching |
| Async mutation continuation scheduling | Manual | Yes | Large writes default to async continuation |
| Sync mode override | Manual | Yes | .execute({ mode: 'sync' }) or schema default |
| Mutation safety/runtime defaults | Manual | Yes | mutationBatchSize, mutationMaxRows, schedule caps, byte budgets |
| Delete modes (hard / soft / scheduled) | Manual | Yes | Per-query mode + table defaults via deletion(...) |
| Runtime unique / FK / check enforcement | App code responsibility | Yes | Enforced for ORM mutations at runtime |
Foreign-key actions (cascade / set null / set default) | App code responsibility | Yes | Runtime fan-out behavior in ORM layer |
Access Control and Lifecycle Hooks
| Capability | Vanilla Convex (ctx.db) | ORM (ctx.orm) | Notes |
|---|---|---|---|
| Table-level RLS policies | No | Yes | rlsPolicy(...) + convexTable.withRLS(...) |
Role-scoped RLS (to + roleResolver) | No | Yes | Role rules require resolver wiring |
| Trusted bypass path | Direct access always bypasses ORM rules | skipRules and ctx.db bypass | Useful for admin/migration paths |
| Schema-level triggers | No | Yes | defineTriggers(...) with create/update/delete/change hooks |
| Trigger integration with aggregates | Manual | Yes | aggregateIndex and rankIndex backfill automatically; change triggers available for custom side effects |
| Trigger recursion controls | Manual | Yes | ctx.db wrapped + ctx.innerDb raw escape hatch |
| Cascade writes and child-table RLS re-check | Manual behavior | Not re-checked | ORM docs: cascade fan-out runs after root check and bypasses child-table RLS |
| Aggregate/count in RLS-restricted contexts | Manual/custom | Explicitly blocked | COUNT_RLS_UNSUPPORTED / AGGREGATE_RLS_UNSUPPORTED |
What Vanilla Convex Provides
- Real-time subscriptions - WebSocket-based, automatic updates
- TanStack Query adapter -
@convex-dev/react-querywithconvexQuery() - Type-safe functions -
query,mutation,actionwith Convex validators - SSR support -
preloadQuery()for server-side data fetching - React hooks -
useQuery,useMutationfor reactive UI
What kitcn Adds
Server
| Feature | Description |
|---|---|
| Fluent Builder | .input().use().query() chained API |
| Zod Validation | Schema reuse, refinements, transforms |
| Destructured Params | { ctx, input } instead of (ctx, args) |
| Middleware | .use() chains with next({ ctx }) |
| Middleware Composition | .pipe() for extending middleware |
| Typed Metadata | .meta() accessible in middleware |
| Internal Procedures | .internal() method |
| Paginated Procedures | .paginated({ limit, item }) method |
| CRPCError | Typed codes with HTTP status mapping |
| Server Caller | Unified caller.x.y({}) proxy |
Client
| Feature | Description |
|---|---|
| TanStack Query | Full API: isPending, isError, refetch(), DevTools |
| Query Keys | queryKey(), queryFilter() for cache manipulation |
| Subscription Control | subscribe: false for one-time fetch |
| Auth-aware | skipUnauth: true auto-skips when unauthenticated |
| Placeholder Data | Skeleton UI support |
| Type Inference | inferApiInputs, inferApiOutputs helpers |
| Mutation Callbacks | onSuccess, onError, onMutate, onSettled |
| Error Handling | Typed errors with error.data?.message |
Next.js
| Feature | Description |
|---|---|
prefetch() | Fire-and-forget, non-blocking, hydrated to client |
HydrateClient | Automatic hydration, no prop drilling |
caller | Direct server calls for RSC/API routes |
Infinite Queries
| Convex | kitcn |
|---|---|
results | data (flattened array), pages (raw page arrays) |
status === 'LoadingFirstPage' | isLoading |
status === 'CanLoadMore' | hasNextPage |
status === 'LoadingMore' | isFetchingNextPage |
loadMore(n) | fetchNextPage(n) |
Syntax Comparison
Defining Queries
Vanilla Convex:
import { query } from 'convex/server';
import { v } from 'convex/values';
export const get = query({
args: { id: v.id('user') },
handler: async (ctx, args) => {
return ctx.db.get(args.id);
},
});kitcn:
import { z } from 'zod';
import { publicQuery } from '../lib/crpc';
export const get = publicQuery
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.get(input.id);
});Using Queries
Vanilla Convex with TanStack Query:
import { useQuery } from '@tanstack/react-query';
import { convexQuery } from '@convex-dev/react-query';
const { data, isPending } = useQuery(convexQuery(api.user.get, { id }));kitcn:
import { useQuery } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
const crpc = useCRPC();
const { data, isPending } = useQuery(crpc.user.get.queryOptions({ id }));
// Auth-aware (skips when logged out)
const { data: me } = useQuery(crpc.user.me.queryOptions({}, { skipUnauth: true }));
// One-time fetch (no WebSocket subscription)
const { data } = useQuery(crpc.user.get.queryOptions({ id }, { subscribe: false }));Mutations
Vanilla Convex:
const mutate = useMutation(api.user.create);
await mutate({ name: 'John' });kitcn:
const crpc = useCRPC();
const { mutate, isPending } = useMutation(
crpc.user.create.mutationOptions({
onSuccess: () => toast.success('Created!'),
onError: (error) => toast.error(error.data?.message ?? 'Failed'),
})
);Authentication Middleware
Vanilla Convex (repeated in every function):
export const me = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error('Unauthenticated');
return ctx.db.query('user').filter(q =>
q.eq(q.field('tokenIdentifier'), identity.tokenIdentifier)
).first();
},
});kitcn (reusable middleware):
// Define once in crpc.ts
export const authQuery = c.query.use(async ({ ctx, next }) => {
const session = await getSession(ctx);
if (!session) throw new CRPCError({ code: 'UNAUTHORIZED' });
const user = await ctx.db.get(session.userId);
return next({ ctx: { ...ctx, user, userId: user.id } });
});
// Use everywhere
export const me = authQuery.query(async ({ ctx }) => ctx.user);Server-Side Calls (Next.js)
Vanilla Convex:
import { preloadQuery } from 'convex/nextjs';
export default async function Page() {
const preloaded = await preloadQuery(api.user.list);
return <UserList preloadedUsers={preloaded} />;
}
// Client component must use usePreloadedQuery
function UserList({ preloadedUsers }) {
const users = usePreloadedQuery(preloadedUsers);
}kitcn:
import { prefetch, HydrateClient } from '@/lib/convex/server';
export default async function Page() {
// Non-blocking prefetch, hydrated to client
prefetch(crpc.user.list.queryOptions({}));
return (
<HydrateClient>
<UserList />
</HydrateClient>
);
}
// Client component uses standard useQuery
function UserList() {
const { data: users } = useQuery(crpc.user.list.queryOptions({}));
}Error Handling
Vanilla Convex:
throw new ConvexError({ message: 'Not found' });kitcn:
// Server - typed codes with HTTP mapping
throw new CRPCError({
code: 'NOT_FOUND', // Maps to HTTP 404
message: 'User not found',
cause: originalError,
});
// Client - error checking
import { isCRPCError, isCRPCErrorCode } from 'kitcn/react';
if (isCRPCErrorCode(error, 'NOT_FOUND')) {
// Handle 404
}Infinite Queries
Vanilla Convex:
const { results, status, loadMore } = usePaginatedQuery(
api.user.list,
{},
{ initialNumItems: 10 }
);
const canLoadMore = status === 'CanLoadMore';kitcn:
import { skipToken } from '@tanstack/react-query';
const crpc = useCRPC();
const {
data, // Flattened array
pages, // Raw page arrays
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteQuery(
crpc.user.list.infiniteQueryOptions(enabled ? {} : skipToken)
);When to Use kitcn
Use kitcn when you need:
- Middleware chains for auth, validation, rate limiting
- Server-side calls without prop drilling (
prefetch,caller) - Auth-aware query skipping (
skipUnauth) - TanStack Query features (DevTools, cache manipulation, callbacks)
- Zod schemas shared between client and server
- tRPC-style fluent builder API
Stick with vanilla Convex when:
- Building a simple prototype
- Don't need middleware or server-side calls or real-time infinite queries