kitcn

Setup

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

Installation

First, install the required packages:

npm install kitcn @tanstack/react-query
pnpm add kitcn @tanstack/react-query
yarn add kitcn @tanstack/react-query
bun add kitcn @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 { 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.

  • Date support 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
src/lib/convex/crpc.tsx
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

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 '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:

src/lib/convex/convex-provider.tsx
'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:

src/lib/convex/convex-provider.tsx
'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":

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

See Auth Server for backend configuration.

Next Steps

API Reference

createCRPCContext

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

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:

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:

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

On this page