BETTER-CONVEX

Comparison

How better-convex extends vanilla Convex.

Overview

better-convex 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 Convexbetter-convex 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.

What Vanilla Convex Provides

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

What better-convex 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

Convexbetter-convex
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 './_generated/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);
  },
});

better-convex:

import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod';
import { publicQuery } from '../lib/crpc';

export const get = publicQuery
  .input(z.object({ id: zid('user') }))
  .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 }));

better-convex:

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' });

better-convex:

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();
  },
});

better-convex (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);
}

better-convex:

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' });

better-convex:

// 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 'better-convex/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';

better-convex:

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 better-convex

Use better-convex 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