BETTER-CONVEX

Migrations

From Convex

Migrate from vanilla Convex to kitcn step by step.

In this guide, we'll explore migrating from vanilla Convex to kitcn. You'll learn to convert functions incrementally, set up middleware, and migrate client hooks while keeping existing code working.

Overview

Migrate incrementally - kitcn works alongside vanilla Convex. Convert functions one at a time:

AspectWhat Changes
Validatorsv.string()z.string()
Argumentsargs: { ... }.input(z.object({ ... }))
Handler params(ctx, args)({ ctx, input })
ErrorsConvexErrorCRPCError with codes
MiddlewarecustomQuery.use() with next()
Client hooksuseQuery(api.x)useQuery(crpc.x.queryOptions({}))

Let's migrate step by step.

Step 1: Install

bun add kitcn @tanstack/react-query zod

Step 2: Server Setup

Create cRPC Builder

convex/lib/crpc.ts
import { initCRPC } from 'kitcn/server';
import type { DataModel } from '../functions/_generated/dataModel';

const c = initCRPC
  .dataModel<DataModel>()
  .create();

export const publicQuery = c.query;
export const publicMutation = c.mutation;
export const publicAction = c.action;

Context - What Stays the Same

Base context properties work identically:

  • ctx.db - Database reader/writer
  • ctx.auth - Authentication
  • ctx.storage - File storage
  • ctx.scheduler - Scheduler (mutations only)

Context - What's New

  • Destructured params: { ctx, input } instead of (ctx, args)
  • Ents support: ctx.table() if configured with .context()

Step 3: Migrate Procedures

Queries

Before (vanilla Convex)
import { query } from 'convex/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);
  },
});
After (cRPC)
import { z } from 'zod';
import { publicQuery } from '../lib/crpc';

export const get = publicQuery
  .input(z.object({ id: z.string() }))
  .query(async ({ ctx, input }) => {
    return ctx.db.get(input.id);
  });

Mutations

Before (vanilla Convex)
export const create = mutation({
  args: { name: v.string(), email: v.string() },
  handler: async (ctx, args) => {
    return ctx.db.insert('user', args);
  },
});
After (cRPC)
export const create = publicMutation
  .input(z.object({ name: z.string(), email: z.string() }))
  .mutation(async ({ ctx, input }) => {
    return ctx.db.insert('user', input);
  });

Actions

Before (vanilla Convex)
export const sendEmail = action({
  args: { to: v.string(), subject: v.string() },
  handler: async (ctx, args) => {
    await fetch('https://api.email.com/send', { ... });
  },
});
After (cRPC)
export const sendEmail = publicAction
  .input(z.object({ to: z.string(), subject: z.string() }))
  .action(async ({ ctx, input }) => {
    await fetch('https://api.email.com/send', { ... });
  });

Internal Procedures

Before (vanilla Convex)
import { internalQuery } from 'convex/server';

export const internal_get = internalQuery({
  args: { id: v.id('user') },
  handler: async (ctx, args) => ctx.db.get(args.id),
});
After (cRPC)
export const internal_get = publicQuery
  .internal()
  .input(z.object({ id: z.string() }))
  .query(async ({ ctx, input }) => ctx.db.get(input.id));

Paginated Procedures

Before (vanilla Convex)
import { query } from 'convex/server';
import { paginationOptsValidator } from 'convex/server';

export const list = query({
  args: { paginationOpts: paginationOptsValidator },
  handler: async (ctx, args) => {
    return ctx.db.query('user').order('desc').paginate(args.paginationOpts);
  },
});
After (cRPC)
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string(),
});

export const list = publicQuery
  .paginated({ limit: 20, item: UserSchema })
  .query(async ({ ctx, input }) => {
    return ctx.db.query('user').order('desc').paginate({ cursor: input.cursor, numItems: input.limit });
  });

The output is automatically typed as { continueCursor, isDone, page: User[] }.

Step 4: Add Middleware

Authentication

Before (inline 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();
  },
});
After (cRPC middleware)
convex/lib/crpc.ts
import { getSession } from 'kitcn/auth';
import { CRPCError } from 'kitcn/server';

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);
Before (convex-helpers customQuery)
import { customQuery, customCtx } from 'convex-helpers/server/customFunctions';

const authQuery = customQuery(query, customCtx(async (ctx) => {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new Error('Unauthenticated');
  const user = await ctx.db.query('user')
    .filter(q => q.eq(q.field('tokenIdentifier'), identity.tokenIdentifier))
    .first();
  return { user };
}));

// Usage
export const me = authQuery({
  args: {},
  handler: async (ctx) => ctx.user,
});
After (cRPC middleware)
convex/lib/crpc.ts
import { getSession } from 'kitcn/auth';
import { CRPCError } from 'kitcn/server';

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

Middleware Composition

Chain multiple middleware with .use():

const loggedQuery = c.query.use(async ({ ctx, next }) => {
  console.log('Query started');
  const result = await next({ ctx });
  console.log('Query finished');
  return result;
});

const authLoggedQuery = loggedQuery.use(async ({ ctx, next }) => {
  // Auth check...
  return next({ ctx: { ...ctx, userId } });
});

Extend existing middleware with .pipe():

const adminQuery = authQuery.pipe(async ({ ctx, next }) => {
  if (!ctx.user.isAdmin) {
    throw new CRPCError({ code: 'FORBIDDEN' });
  }
  return next({ ctx });
});

Typed Metadata

New (typed metadata):

const c = initCRPC
  .dataModel<DataModel>()
  .meta<{ ratelimit?: number }>()
  .create({ ... });

export const limited = c.query
  .meta({ ratelimit: 100 })
  .use(async ({ ctx, meta, next }) => {
    if (meta.ratelimit) {
      await checkRatelimit(ctx, meta.ratelimit);
    }
    return next({ ctx });
  })
  .query(async ({ ctx }) => { ... });

Step 5: Error Handling

Server - CRPCError

Before (vanilla Convex)
throw new ConvexError({ message: 'Not found' });
After (cRPC)
import { CRPCError } from 'kitcn/server';

throw new CRPCError({
  code: 'NOT_FOUND',  // Maps to HTTP 404
  message: 'User not found',
  cause: originalError,
});

Available codes: UNAUTHORIZED, FORBIDDEN, NOT_FOUND, BAD_REQUEST, CONFLICT, INTERNAL_SERVER_ERROR, etc.

Client - Error States

Before (vanilla Convex)
try {
  await mutate(args);
} catch (error) {
  console.error(error.message);
}
After (cRPC)
const { mutate, error, isError } = useMutation(
  crpc.user.create.mutationOptions({
    onError: (error) => {
      toast.error(error.data?.message ?? 'Failed');
    },
  })
);

// Or check error type
import { isCRPCErrorCode } from 'kitcn/react';

if (isCRPCErrorCode(error, 'NOT_FOUND')) {
  // Handle 404
}

Step 6: Client Setup

Provider Setup

src/app/providers.tsx
'use client';

import { QueryClient, 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!);

function createQueryClient() {
  return new QueryClient({
    defaultOptions: { queries: { staleTime: Infinity } },
  });
}

export function Providers({ 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>
  );
}

Create cRPC Context

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

Generate Metadata

bunx kitcn dev

Step 7: Migrate Client Hooks

Queries

Before (vanilla Convex)
import { useQuery } from 'convex/react';

const user = useQuery(api.user.get, { id });
if (user === undefined) return <div>Loading...</div>;
After (cRPC)
import { useQuery } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';

const crpc = useCRPC();
const { data: user, isPending } = useQuery(crpc.user.get.queryOptions({ id }));

if (isPending) return <div>Loading...</div>;

New options:

// Skip when not authenticated
const { data } = useQuery(crpc.user.me.queryOptions({}, { skipUnauth: true }));

// One-time fetch (no WebSocket subscription)
const { data } = useQuery(crpc.user.get.queryOptions({ id }, { subscribe: false }));

// Placeholder data for skeletons
const { data } = useQuery(crpc.user.get.queryOptions({ id }, {
  placeholderData: { name: 'Loading...', email: '' },
}));

Mutations

Before (vanilla Convex)
const createUser = useMutation(api.user.create);
await createUser({ name: 'John' });
After (cRPC)
const { mutate, mutateAsync, isPending } = useMutation(
  crpc.user.create.mutationOptions({
    onSuccess: () => toast.success('Created!'),
    onError: (error) => toast.error(error.data?.message ?? 'Failed'),
    onMutate: () => { /* optimistic update */ },
    onSettled: () => { /* cleanup */ },
  })
);

mutate({ name: 'John' });
// or
await mutateAsync({ name: 'John' });

Infinite Queries

Before (vanilla Convex)
const { results, status, loadMore } = usePaginatedQuery(
  api.user.list,
  {},
  { initialNumItems: 10 }
);

const isLoading = status === 'LoadingFirstPage';
const canLoadMore = status === 'CanLoadMore';
After (cRPC)
import { skipToken } from '@tanstack/react-query';
import { useInfiniteQuery } from 'kitcn/react';

const {
  data,           // Flattened array (was: results)
  pages,          // Raw page arrays
  isLoading,      // (was: status === 'LoadingFirstPage')
  hasNextPage,    // (was: status === 'CanLoadMore')
  isFetchingNextPage, // (was: status === 'LoadingMore')
  fetchNextPage,  // (was: loadMore)
} = useInfiniteQuery(
  crpc.user.list.infiniteQueryOptions(enabled ? {} : skipToken)
);

Step 8: Next.js/RSC (Optional)

Three Server Patterns

1. prefetch() - Fire-and-forget, non-blocking (NEW):

import { crpc, prefetch, HydrateClient } from '@/lib/convex/rsc';

export default async function Page() {
  prefetch(crpc.user.list.queryOptions({}));

  return (
    <HydrateClient>
      <UserList />
    </HydrateClient>
  );
}

// Client uses standard useQuery
function UserList() {
  const { data } = useQuery(crpc.user.list.queryOptions({}));
}

2. caller - Direct server calls:

import { caller } from '@/lib/convex/rsc';

export default async function Page() {
  const users = await caller.user.list({});
  return <UserList users={users} />;
}

3. preloadQuery() - Awaited, data + hydration:

import { crpc, preloadQuery, HydrateClient } from '@/lib/convex/rsc';

export default async function Page() {
  const users = await preloadQuery(crpc.user.list.queryOptions({}));

  return (
    <HydrateClient>
      <UserList initialData={users} />
    </HydrateClient>
  );
}

Server + RSC Setup

src/lib/convex/server.ts
import { api } from '@convex/api';
import { createCallerFactory } from 'kitcn/server';

export const { createContext, createCaller } = createCallerFactory({
  api,
  convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});
src/lib/convex/rsc.tsx
import 'server-only';

import { api } from '@convex/api';
import { createServerCRPCProxy } from 'kitcn/rsc';
import { cache } from 'react';
import { headers } from 'next/headers';

import { createCaller, createContext } from './server';

const createRSCContext = cache(async () => createContext({ headers: await headers() }));
export const caller = createCaller(createRSCContext);
export const crpc = createServerCRPCProxy({ api });

Quick Reference

AspectVanilla Convexkitcn
Validatorsv.string()z.string()
Argumentsargs: { ... }.input(z.object({ ... }))
Handler params(ctx, args)({ ctx, input })
InternalinternalQuery().internal().query()
PaginatedpaginationOptsValidator.paginated({ limit, item })
ErrorsConvexErrorCRPCError with codes
MiddlewarecustomQuery/customCtx.use() with next()
Client hooksuseQuery(api.x)useQuery(crpc.x.queryOptions({}))
Loading statedata === undefinedisPending
MutationsuseMutation(api.x)useMutation(crpc.x.mutationOptions())
InfiniteusePaginatedQueryuseInfiniteQuery
Server callspreloadQueryprefetch, caller

Incremental Migration Tips

  1. Keep both patterns - Vanilla and cRPC functions coexist
  2. Migrate by module - Convert one file at a time
  3. Start with queries - Lower risk than mutations
  4. Add middleware last - After basic migration works
  5. Test as you go - Each migration is isolated
// Both work in the same file
export const legacyList = query({ handler: async (ctx) => { ... } });
export const list = publicQuery.query(async ({ ctx }) => { ... });

Next Steps

On this page