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:
| Aspect | What Changes |
|---|---|
| Validators | v.string() → z.string() |
| Arguments | args: { ... } → .input(z.object({ ... })) |
| Handler params | (ctx, args) → ({ ctx, input }) |
| Errors | ConvexError → CRPCError with codes |
| Middleware | customQuery → .use() with next() |
| Client hooks | useQuery(api.x) → useQuery(crpc.x.queryOptions({})) |
Let's migrate step by step.
Step 1: Install
bun add kitcn @tanstack/react-query zodStep 2: Server Setup
Create cRPC Builder
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/writerctx.auth- Authenticationctx.storage- File storagectx.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
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);
},
});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
export const create = mutation({
args: { name: v.string(), email: v.string() },
handler: async (ctx, args) => {
return ctx.db.insert('user', args);
},
});export const create = publicMutation
.input(z.object({ name: z.string(), email: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.insert('user', input);
});Actions
export const sendEmail = action({
args: { to: v.string(), subject: v.string() },
handler: async (ctx, args) => {
await fetch('https://api.email.com/send', { ... });
},
});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
import { internalQuery } from 'convex/server';
export const internal_get = internalQuery({
args: { id: v.id('user') },
handler: async (ctx, args) => ctx.db.get(args.id),
});export const internal_get = publicQuery
.internal()
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => ctx.db.get(input.id));Paginated Procedures
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);
},
});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
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();
},
});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);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,
});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
throw new ConvexError({ message: 'Not found' });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
try {
await mutate(args);
} catch (error) {
console.error(error.message);
}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
'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
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 devStep 7: Migrate Client Hooks
Queries
import { useQuery } from 'convex/react';
const user = useQuery(api.user.get, { id });
if (user === undefined) return <div>Loading...</div>;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
const createUser = useMutation(api.user.create);
await createUser({ name: 'John' });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
const { results, status, loadMore } = usePaginatedQuery(
api.user.list,
{},
{ initialNumItems: 10 }
);
const isLoading = status === 'LoadingFirstPage';
const canLoadMore = status === 'CanLoadMore';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
import { api } from '@convex/api';
import { createCallerFactory } from 'kitcn/server';
export const { createContext, createCaller } = createCallerFactory({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});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
| Aspect | Vanilla Convex | kitcn |
|---|---|---|
| Validators | v.string() | z.string() |
| Arguments | args: { ... } | .input(z.object({ ... })) |
| Handler params | (ctx, args) | ({ ctx, input }) |
| Internal | internalQuery() | .internal().query() |
| Paginated | paginationOptsValidator | .paginated({ limit, item }) |
| Errors | ConvexError | CRPCError with codes |
| Middleware | customQuery/customCtx | .use() with next() |
| Client hooks | useQuery(api.x) | useQuery(crpc.x.queryOptions({})) |
| Loading state | data === undefined | isPending |
| Mutations | useMutation(api.x) | useMutation(crpc.x.mutationOptions()) |
| Infinite | usePaginatedQuery | useInfiniteQuery |
| Server calls | preloadQuery | prefetch, caller |
Incremental Migration Tips
- Keep both patterns - Vanilla and cRPC functions coexist
- Migrate by module - Convert one file at a time
- Start with queries - Lower risk than mutations
- Add middleware last - After basic migration works
- 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 }) => { ... });