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 kitcn @tanstack/react-queryCreate the cRPC Context
Create a file that exports your cRPC hooks:
import { api } from '@convex/api';
import { createCRPCContext } from 'kitcn/react';
export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});Note: api is generated by kitcn dev and already includes procedure metadata used by cRPC.
Payload Transformer (Optional)
createCRPCContext also accepts transformer.
Datesupport is always enabled (cannot be disabled)- The reserved wire tag is
$date - Use custom transformer only when you need extra wire-safe types
- Custom transformer is additive to the built-in Date transformer
import { api } from '@convex/api';
import { createTaggedTransformer } from 'kitcn/crpc';
import { createCRPCContext } from 'kitcn/react';
const transformer = createTaggedTransformer([
// Add custom codecs here (Date is already built-in)
]);
export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
transformer,
});Exports
See createCRPCContext returns in the API Reference.
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 'kitcn/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 'kitcn/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 AppConvexProvider({ 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 'kitcn/auth/client';
import {
ConvexReactClient,
getConvexQueryClientSingleton,
getQueryClientSingleton,
useAuthStore,
} from 'kitcn/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 AppConvexProvider({
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.
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
import { ConvexProvider, ConvexReactClient } from 'convex/react';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
<ConvexProvider client={convex}>
{children}
</ConvexProvider>import { QueryClientProvider } from '@tanstack/react-query';
import {
ConvexReactClient,
getQueryClientSingleton,
getConvexQueryClientSingleton,
} from 'kitcn/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 |
Next Steps
API Reference
createCRPCContext
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 |
Singleton Helpers
cRPC provides singleton helpers to ensure consistent client instances across renders and SSR.
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:
| 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)