BETTER-CONVEX

Setup

Set up cRPC with TanStack Query and real-time subscriptions.

In this guide, we'll set up cRPC's React integration. You'll learn to configure TanStack Query with Convex's real-time WebSocket subscriptions, create the provider hierarchy, and understand how caching works with push-based updates.

Overview

cRPC's client integrates TanStack Query with Convex's real-time WebSocket subscriptions:

FeatureBenefit
Real-time syncWebSocket subscriptions update TanStack Query cache
Familiar APITanStack Query hooks, caching, and devtools
Auth-awareSkips queries when unauthenticated
SSR supportSingleton helpers ensure consistent instances

Let's set it up.

Installation

First, install the required packages:

bun add better-convex @tanstack/react-query

Create the cRPC Context

Create a file that exports your cRPC hooks:

src/lib/convex/crpc.tsx
import { api } from '@convex/api';
import { meta } from '@convex/meta';
import type { Api } from '@convex/types';
import { createCRPCContext } from 'better-convex/react';

export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext<Api>({
  api,
  meta,
  convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});

Note: meta is generated by better-convex dev. See CLI for details.

Exports

Here's what createCRPCContext returns:

ExportDescription
CRPCProviderReact context provider for cRPC
useCRPCHook returning the cRPC proxy for queryOptions/mutationOptions
useCRPCClientHook returning the typed vanilla cRPC client for direct procedural calls

Create a QueryClient

Now let's configure TanStack QueryClient for Convex real-time. cRPC sets staleTime: Infinity and refetch*: false on each query automatically (Convex handles freshness via WebSocket):

src/lib/convex/query-client.ts
import {
  type DefaultOptions,
  defaultShouldDehydrateQuery,
  QueryCache,
  QueryClient,
} from '@tanstack/react-query';
import { isCRPCClientError, isCRPCError } from 'better-convex/crpc';
import { toast } from 'sonner';
import SuperJSON from 'superjson';

/** Shared hydration config for SSR (client + server) */
export const hydrationConfig: Pick<DefaultOptions, 'dehydrate' | 'hydrate'> = {
  dehydrate: {
    serializeData: SuperJSON.serialize,
    shouldDehydrateQuery: (query) =>
      defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
    shouldRedactErrors: () => false,
  },
  hydrate: {
    deserializeData: SuperJSON.deserialize,
  },
};

export function createQueryClient() {
  return new QueryClient({
    queryCache: new QueryCache({
      onError: (error) => {
        if (isCRPCClientError(error)) {
          console.log(`[CRPC] ${error.code}:`, error.functionName);
        }
      },
    }),
    defaultOptions: {
      ...hydrationConfig,
      mutations: {
        onError: (err) => {
          const error = err as Error & { data?: { message?: string } };
          toast.error(error.data?.message || error.message);
        },
      },
      queries: {
        retry: (failureCount, error) => {
          // Don't retry deterministic CRPC errors (auth, validation, HTTP 4xx)
          if (isCRPCError(error)) return false;

          const message =
            error instanceof Error ? error.message : String(error);

          // Retry timeouts
          if (message.includes('timed out') && failureCount < 3) {
            console.warn(
              `[QueryClient] Retrying timed out query (attempt ${failureCount + 1}/3)`
            );
            return true;
          }

          return failureCount < 3;
        },
        retryDelay: (attemptIndex) =>
          Math.min(2000 * 2 ** attemptIndex, 30_000),
      },
    },
  });
}

Provider Setup

Now let's wire everything together. Choose the setup that matches your app.

Without Auth

For public-only apps without authentication:

src/lib/convex/convex-provider.tsx
'use client';

import { QueryClientProvider } from '@tanstack/react-query';
import {
  ConvexProvider,
  ConvexReactClient,
  getQueryClientSingleton,
  getConvexQueryClientSingleton,
} from 'better-convex/react';
import { CRPCProvider } from '@/lib/convex/crpc';
import { createQueryClient } from '@/lib/convex/query-client';

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export function BetterConvexProvider({ children }: { children: React.ReactNode }) {
  return (
    <ConvexProvider client={convex}>
      <QueryProvider>{children}</QueryProvider>
    </ConvexProvider>
  );
}

function QueryProvider({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClientSingleton(createQueryClient);
  const convexQueryClient = getConvexQueryClientSingleton({
    convex,
    queryClient,
  });

  return (
    <QueryClientProvider client={queryClient}>
      <CRPCProvider convexClient={convex} convexQueryClient={convexQueryClient}>
        {children}
      </CRPCProvider>
    </QueryClientProvider>
  );
}

With Auth

For apps with authentication, use ConvexAuthProvider instead of ConvexProvider:

src/lib/convex/convex-provider.tsx
'use client';

import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ConvexAuthProvider } from 'better-convex/auth-client';
import {
  ConvexReactClient,
  getConvexQueryClientSingleton,
  getQueryClientSingleton,
  useAuthStore,
} from 'better-convex/react';
import { useRouter } from 'next/navigation';
import type { ReactNode } from 'react';

import { authClient } from '@/lib/convex/auth-client';
import { CRPCProvider } from '@/lib/convex/crpc';
import { createQueryClient } from '@/lib/convex/query-client';

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export function BetterConvexProvider({
  children,
  token,
}: {
  children: ReactNode;
  token?: string;
}) {
  const router = useRouter();

  return (
    <ConvexAuthProvider
      authClient={authClient}
      client={convex}
      initialToken={token}
      onMutationUnauthorized={() => router.push('/login')}
      onQueryUnauthorized={() => router.push('/login')}
    >
      <QueryProvider>{children}</QueryProvider>
    </ConvexAuthProvider>
  );
}

function QueryProvider({ children }: { children: ReactNode }) {
  const authStore = useAuthStore();

  const queryClient = getQueryClientSingleton(createQueryClient);
  const convexQueryClient = getConvexQueryClientSingleton({
    authStore,
    convex,
    queryClient,
  });

  return (
    <QueryClientProvider client={queryClient}>
      <CRPCProvider convexClient={convex} convexQueryClient={convexQueryClient}>
        {children}
        <ReactQueryDevtools initialIsOpen={false} />
      </CRPCProvider>
    </QueryClientProvider>
  );
}

Key differences from "Without Auth":

FeatureDescription
ConvexAuthProviderWraps everything with auth state management
useAuthStore()Passes auth state to getConvexQueryClientSingleton
onQueryUnauthorizedHandles auth failures with redirect

See Auth Server for backend configuration.

Singleton Helpers

cRPC provides singleton helpers to ensure consistent client instances across renders and SSR. Let's explore each one.

getQueryClientSingleton

Returns the same QueryClient instance on the client, creates a fresh one during SSR:

const queryClient = getQueryClientSingleton(createQueryClient);

getConvexQueryClientSingleton

Creates and connects the ConvexQueryClient that bridges TanStack Query with Convex subscriptions:

const convexQueryClient = getConvexQueryClientSingleton({
  convex,             // ConvexReactClient instance
  queryClient,        // TanStack QueryClient
  unsubscribeDelay,   // Optional: delay before unsubscribing (default: 3s)
});

unsubscribeDelay

Controls how long subscriptions stay open after unmount. Prevents wasteful unsubscribe/subscribe cycles from React StrictMode and quick back/forward navigation:

ValueUse Case
0Unsubscribe immediately (minimal server resources)
3000 (default)Covers StrictMode + quick "oops" back navigations
10000Generous buffer for browsing patterns with frequent back/forward

Tip: Even after unsubscribing, cached data persists for gcTime (5 min). On back-navigation, you see cached data instantly while a new subscription fetches any updates.

ConvexQueryClient

The ConvexQueryClient bridges TanStack Query with Convex's real-time subscriptions. Here's what it provides:

FeatureDescription
Real-time syncWebSocket subscriptions update TanStack Query cache
Auth-aware queriesSkips queries marked auth: 'required' when unauthenticated
Subscription lifecycleSubscribes on mount, unsubscribes after delay on unmount

Caching vs Subscriptions

Unlike traditional REST APIs that use polling, Convex pushes updates via WebSocket. This changes how caching works:

┌─────────────────────────────────────────────────────────────────┐
│                    Traditional REST API                         │
│                                                                 │
│  fetch() ──► cache ──► staleTime expires ──► refetch()         │
│                              │                                  │
│                        (pull model)                             │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                    Convex + cRPC                                │
│                                                                 │
│  useQuery() ──► WebSocket subscription ──► real-time updates   │
│                        │                          │             │
│                   (push model)            cache always fresh    │
└─────────────────────────────────────────────────────────────────┘

Key QueryClient defaults:

SettingValueWhy
staleTimeInfinityData is never "stale" - Convex pushes updates via WebSocket
gcTime5 minCached data persists for back-navigation after unmount
refetchOnMountfalseNo need to refetch - subscription provides latest data
refetchOnWindowFocusfalseWebSocket already pushed any changes

Subscription Lifecycle

WebSocket subscriptions are decoupled from cache retention. Here's the flow:

Mount ──► Subscribe to WebSocket ──► Receive updates ──► Unmount

                                                   Wait unsubscribeDelay (3s)

                                              ┌─────────────┴─────────────┐
                                              │                           │
                                        Re-mount in time           Delay expires
                                              │                           │
                                     Cancel unsubscribe              Unsubscribe
                                     (subscription stays)       (free server resources)

                                                                  Cache data persists
                                                                     (gcTime: 5 min)

                                                        ┌─────────────────┴─────────────────┐
                                                        │                                   │
                                                 Re-mount in time                    gcTime expires
                                                        │                                   │
                                                 Instant cached data              Cache cleared
                                                 + new subscription               (fresh fetch)

Migrate from Convex

If you're coming from vanilla Convex, here's what changes.

What stays the same

  • ConvexReactClient for WebSocket connection
  • Real-time data synchronization

What's new

Before (vanilla Convex):

import { ConvexProvider, ConvexReactClient } from 'convex/react';

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

<ConvexProvider client={convex}>
  {children}
</ConvexProvider>

After (cRPC):

import { QueryClientProvider } from '@tanstack/react-query';
import {
  ConvexReactClient,
  getQueryClientSingleton,
  getConvexQueryClientSingleton,
} from 'better-convex/react';
import { CRPCProvider } from '@/lib/convex/crpc';

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

const queryClient = getQueryClientSingleton(createQueryClient);
const convexQueryClient = getConvexQueryClientSingleton({
  convex,
  queryClient,
});

<QueryClientProvider client={queryClient}>
  <CRPCProvider
    convexClient={convex}
    convexQueryClient={convexQueryClient}
  >
    {children}
  </CRPCProvider>
</QueryClientProvider>

Key differences:

FeatureDescription
TanStack QueryClientManages caching and devtools
ConvexQueryClientBridges WebSocket updates to TanStack Query
Singleton helpersEnsure SSR compatibility
Auth-aware handlingConvexAuthProvider for authenticated apps

Next Steps

On this page