BETTER-CONVEX

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:

LayerVanilla Convexkitcn Adds
Serverquery, mutation, actionFluent builder, middleware, Zod
ClientuseQuery, useMutationTanStack Query, auth-aware skipping
SSRpreloadQuery()prefetch(), caller, HydrateClient
ErrorsConvexErrorCRPCError 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

CapabilityVanilla Convex (ctx.db)ORM (ctx.orm)Notes
Relational query builder (findMany / findFirst / with)NoYesDrizzle-style read surface
findFirstOrThrow()NoYesThrows when no row matches
Object where filtersManual query/filter codeYesIncludes comparison/logical/string operators
Callback where ((table, ops) => ...)No equivalentYesDrizzle-style callback helpers
Explicit index anchoring (withIndex(name, range?))Manual low-level index APIYesRequired for predicate where; available for index-first planning
Predicate where with explicit .withIndex(...)Manual onlyYesRequired for ops.predicate(...); no implicit full scan fallback
Relation filters (where: { relation: ... })NoYesNested relation predicates in reads
Eager relation loading (with)Manual multi-queryYesIncludes nested relation loading
Bounded scan controls (allowFullScan, maxScan)Manual scan controlYesGuardrails for fallback paths and full-scan opt-in
Search index queryingYesYesORM surface: findMany({ search })
Vector index queryingYesYesORM surface: findMany({ vectorSearch })
Cursor paginationYesYesORM enforces typed cursor + limit usage
Offset paginationYesYesSupported, but deep offsets are still expensive
Projection/computed fields (columns, extras)Manual mappingYesComputed/projection work runs post-fetch (no SQL computed columns)
Multi-field orderByManual sort logicYesFirst field drives cursor stability; secondary fields may sort post-fetch
Unsized non-paginated reads guardrailNo built-in ORM-style guardrailYesThrows unless limit, cursor pagination, defaultLimit, or allowFullScan
Relation fan-out key capNo built-in ORM-style guardrailYesrelationFanOutMaxKeys fail-fast guardrail
findMany({ distinct })Manual dedupNoUse select().distinct({ fields }) pipeline when needed

Aggregate and Count

CapabilityVanilla Convex (ctx.db)ORM (ctx.orm)Notes
count()Manual/customYesUnfiltered path uses native Convex count syscall
count({ where })Manual/customYesStrict no-scan; requires matching aggregateIndex
count({ select: { _all, field } })No helperYesField counts require .count(field) metric declaration
count({ orderBy, skip, take, cursor })Manual/customYesWindowed count with ordering and pagination bounds
aggregate({ _count/_sum/_avg/_min/_max })No helperYesPrisma-style aggregate blocks
Scan fallback for aggregate/countManual implementation choiceNoORM aggregate/count never fall back to collect/take scan paths
Allowed aggregate/count filter subsetManual/customeq / in / isNull / range + AND + finite safe OR rewriteStrict no-scan v1 subset
OR in aggregate/count whereManual/customYesSafe finite OR rewrite when each branch is index-plannable
Unsupported aggregate/count filters (NOT / string / relation)Manual/customBlockedDeterministic unsupported-filter errors
aggregate({ orderBy/take/skip/cursor })Manual/customPartialorderBy/cursor supported; skip/take is _count-only in v1
Relation _count in withNo helperYeswith: { _count: { relation: true } }
Relation _count nested relation filterManual/customNoBlocked in v1 (RELATION_COUNT_FILTER_UNSUPPORTED)
Relation _count filtered through() relationManual/customYesIndexed lookups + no-scan-safe filter validation
Mutation returning({ _count })NoYesSplit selection + relation count loading on insert/update/delete
groupBy({ by, _count/_sum/_avg/_min/_max })NoYesFinite-constrained by fields; having/orderBy/skip/take/cursor supported
Window analytics (having / window functions)NoNoDeferred
Ranked access (rankIndex + rank())NoYesORM-native rankIndex for leaderboards, random access, sorted pagination (no @convex-dev/aggregate dependency)
Aggregate read path overheadComponent calls often cross ctx.runQuery boundaryDirect aggregate/count read in current queryORM avoids extra component read boundary for scalar aggregate/count paths

Mutations, Constraints, and Delete Modes

CapabilityVanilla Convex (ctx.db)ORM (ctx.orm)Notes
Builder API (insert / update / delete)NoYesDrizzle-style mutation builders
.returning()NoYesReturns affected rows (always array)
Upserts (onConflictDoNothing / onConflictDoUpdate)No helperYesRuntime-enforced against unique constraints/indexes
Write safety guard (update/delete require where)NoYesMust use where or opt in with allowFullScan
Paginated update/delete batches (.paginate)ManualYesCursor-based write batching
Async mutation continuation schedulingManualYesLarge writes default to async continuation
Sync mode overrideManualYes.execute({ mode: 'sync' }) or schema default
Mutation safety/runtime defaultsManualYesmutationBatchSize, mutationMaxRows, schedule caps, byte budgets
Delete modes (hard / soft / scheduled)ManualYesPer-query mode + table defaults via deletion(...)
Runtime unique / FK / check enforcementApp code responsibilityYesEnforced for ORM mutations at runtime
Foreign-key actions (cascade / set null / set default)App code responsibilityYesRuntime fan-out behavior in ORM layer

Access Control and Lifecycle Hooks

CapabilityVanilla Convex (ctx.db)ORM (ctx.orm)Notes
Table-level RLS policiesNoYesrlsPolicy(...) + convexTable.withRLS(...)
Role-scoped RLS (to + roleResolver)NoYesRole rules require resolver wiring
Trusted bypass pathDirect access always bypasses ORM rulesskipRules and ctx.db bypassUseful for admin/migration paths
Schema-level triggersNoYesdefineTriggers(...) with create/update/delete/change hooks
Trigger integration with aggregatesManualYesaggregateIndex and rankIndex backfill automatically; change triggers available for custom side effects
Trigger recursion controlsManualYesctx.db wrapped + ctx.innerDb raw escape hatch
Cascade writes and child-table RLS re-checkManual behaviorNot re-checkedORM docs: cascade fan-out runs after root check and bypasses child-table RLS
Aggregate/count in RLS-restricted contextsManual/customExplicitly blockedCOUNT_RLS_UNSUPPORTED / AGGREGATE_RLS_UNSUPPORTED

What Vanilla Convex Provides

  • Real-time subscriptions - WebSocket-based, automatic updates
  • TanStack Query adapter - @convex-dev/react-query with convexQuery()
  • Type-safe functions - query, mutation, action with Convex validators
  • SSR support - preloadQuery() for server-side data fetching
  • React hooks - useQuery, useMutation for reactive UI

What kitcn Adds

Server

FeatureDescription
Fluent Builder.input().use().query() chained API
Zod ValidationSchema 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
CRPCErrorTyped codes with HTTP status mapping
Server CallerUnified caller.x.y({}) proxy

Client

FeatureDescription
TanStack QueryFull API: isPending, isError, refetch(), DevTools
Query KeysqueryKey(), queryFilter() for cache manipulation
Subscription Controlsubscribe: false for one-time fetch
Auth-awareskipUnauth: true auto-skips when unauthenticated
Placeholder DataSkeleton UI support
Type InferenceinferApiInputs, inferApiOutputs helpers
Mutation CallbacksonSuccess, onError, onMutate, onSettled
Error HandlingTyped errors with error.data?.message

Next.js

FeatureDescription
prefetch()Fire-and-forget, non-blocking, hydrated to client
HydrateClientAutomatic hydration, no prop drilling
callerDirect server calls for RSC/API routes

Infinite Queries

Convexkitcn
resultsdata (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

Next Steps

On this page