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:
| Feature | Benefit |
|---|---|
| Real-time sync | WebSocket subscriptions update TanStack Query cache |
| Familiar API | TanStack Query hooks, caching, and devtools |
| Auth-aware | Skips queries when unauthenticated |
| SSR support | Singleton helpers ensure consistent instances |
Let's set it up.
Installation
First, install the required packages:
bun add better-convex @tanstack/react-queryCreate the cRPC Context
Create a file that exports your cRPC hooks:
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:
| Export | Description |
|---|---|
CRPCProvider | React context provider for cRPC |
useCRPC | Hook returning the cRPC proxy for queryOptions/mutationOptions |
useCRPCClient | Hook 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):
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:
'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:
'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":
| Feature | Description |
|---|---|
ConvexAuthProvider | Wraps everything with auth state management |
useAuthStore() | Passes auth state to getConvexQueryClientSingleton |
onQueryUnauthorized | Handles 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:
| Value | Use Case |
|---|---|
0 | Unsubscribe immediately (minimal server resources) |
3000 (default) | Covers StrictMode + quick "oops" back navigations |
10000 | Generous 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:
| Feature | Description |
|---|---|
| Real-time sync | WebSocket subscriptions update TanStack Query cache |
| Auth-aware queries | Skips queries marked auth: 'required' when unauthenticated |
| Subscription lifecycle | Subscribes 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:
| Setting | Value | Why |
|---|---|---|
staleTime | Infinity | Data is never "stale" - Convex pushes updates via WebSocket |
gcTime | 5 min | Cached data persists for back-navigation after unmount |
refetchOnMount | false | No need to refetch - subscription provides latest data |
refetchOnWindowFocus | false | WebSocket 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
ConvexReactClientfor 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:
| Feature | Description |
|---|---|
| TanStack QueryClient | Manages caching and devtools |
| ConvexQueryClient | Bridges WebSocket updates to TanStack Query |
| Singleton helpers | Ensure SSR compatibility |
| Auth-aware handling | ConvexAuthProvider for authenticated apps |