Quickstart
Get Better Convex running in 5 minutes.
In this guide, we'll get Better Convex running from scratch. You'll set up a Next.js app with Convex, create your first cRPC procedures, and use them with TanStack Query.
ctx.db vs ctx.table: This guide has tabs for two database access styles. Use ctx.db (vanilla Convex) if you're new. Use ctx.table (Convex Ents) for relational patterns with edges and cascading deletes.
Create Next.js App
bunx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir
cd my-appInstallation
bun add convex better-convex convex-helpers zod @tanstack/react-querybun add convex better-convex convex-helpers zod @tanstack/react-query convex-entsConfigure Folder Structure
Better Convex uses a specific folder structure. Create convex.json:
{
"functions": "convex/functions",
"codegen": {
"staticApi": true,
"staticDataModel": true
}
}The codegen.staticApi setting is required for TypeScript types to work correctly with cRPC.
This enables the recommended structure:
convex/
├── functions/ # Convex functions (configured above)
│ ├── _generated/ # api.ts, dataModel.ts
│ ├── schema.ts # Database schema
│ └── *.ts # Procedures
├── lib/ # Server helpers (NOT deployed)
│ └── crpc.ts # cRPC builder
└── shared/ # Client-importable
└── meta.ts # Generated procedure metadataCreate these folders:
mkdir -p convex/functions convex/lib src/lib/convexNext, add these settings to your existing tsconfig.json:
{
"compilerOptions": {
"strict": true,
"strictFunctionTypes": false,
"paths": {
"@/*": ["./src/*"],
"@convex/*": ["./convex/functions/_generated/*", "./convex/shared/*"]
}
}
}strictFunctionTypes: false is required for middleware type inference to work correctly.
See Concepts for detailed folder structure explanation.
1. Define Schema
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
user: defineTable({
name: v.string(),
email: v.string(),
}).index('email', ['email']),
});import { v } from 'convex/values';
import { defineEnt, defineEntSchema } from 'convex-ents';
const schema = defineEntSchema({
user: defineEnt({
name: v.string(),
email: v.string(),
}).index('email', ['email']),
});
export default schema;2. Initialize Convex
Start the Better Convex dev server:
bunx better-convex devThis generates convex/functions/_generated/ (types) and convex/shared/meta.ts (procedure metadata). Keep it running in a separate terminal.
For one-time codegen without watch mode, use bunx better-convex codegen.
Environment Variables
Create .env.local with your Convex URLs:
# WebSocket API (port 3210)
NEXT_PUBLIC_CONVEX_URL=http://localhost:3210
# HTTP routes (port 3211)
NEXT_PUBLIC_CONVEX_SITE_URL=http://localhost:3211# Generated by Convex
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
# Add manually - replace .cloud with .site
NEXT_PUBLIC_CONVEX_SITE_URL=https://your-project.convex.site3. Create cRPC Builder
import { initCRPC } from 'better-convex/server';
import {
query,
mutation,
internalQuery,
internalMutation,
action,
internalAction,
} from '../functions/_generated/server';
import type {
ActionCtx,
MutationCtx,
QueryCtx,
} from '../functions/_generated/server';
import type { DataModel } from '../functions/_generated/dataModel';
export type GenericCtx = QueryCtx | MutationCtx | ActionCtx;
const c = initCRPC
.dataModel<DataModel>()
.create({
query,
internalQuery,
mutation,
internalMutation,
action,
internalAction,
});
export const publicQuery = c.query;
export const publicMutation = c.mutation;First, create the ents helper:
import type { GenericEnt, GenericEntWriter } from 'convex-ents';
import { entsTableFactory, getEntDefinitions } from 'convex-ents';
import type { TableNames } from '../functions/_generated/dataModel';
import type { MutationCtx, QueryCtx } from '../functions/_generated/server';
import schema from '../functions/schema';
export const entDefinitions = getEntDefinitions(schema);
export type Ent<TableName extends TableNames> = GenericEnt<
typeof entDefinitions,
TableName
>;
export type EntWriter<TableName extends TableNames> = GenericEntWriter<
typeof entDefinitions,
TableName
>;
export const getCtxWithTable = <Ctx extends MutationCtx | QueryCtx>(ctx: Ctx) => ({
...ctx,
table: entsTableFactory(ctx, entDefinitions),
});Then create the cRPC builder:
import { initCRPC } from 'better-convex/server';
import {
query,
mutation,
internalQuery,
internalMutation,
action,
internalAction,
} from '../functions/_generated/server';
import type {
ActionCtx,
MutationCtx,
QueryCtx,
} from '../functions/_generated/server';
import type { DataModel } from '../functions/_generated/dataModel';
import { getCtxWithTable } from './ents';
export type GenericCtx = QueryCtx | MutationCtx | ActionCtx;
const c = initCRPC
.dataModel<DataModel>()
.context({
query: (ctx) => getCtxWithTable(ctx),
mutation: (ctx) => getCtxWithTable(ctx),
})
.create({
query,
internalQuery,
mutation,
internalMutation,
action,
internalAction,
});
export const publicQuery = c.query;
export const publicMutation = c.mutation;4. Define Procedures
import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod4';
import { publicQuery, publicMutation } from '../lib/crpc';
export const list = publicQuery
.input(z.object({ limit: z.number().optional() }))
.output(z.array(z.object({
_id: zid('user'),
_creationTime: z.number(),
name: z.string(),
email: z.string(),
})))
.query(async ({ ctx, input }) => {
return ctx.db.query('user').take(input.limit ?? 10);
});
export const create = publicMutation
.input(z.object({ name: z.string(), email: z.string() }))
.output(zid('user'))
.mutation(async ({ ctx, input }) => {
return ctx.db.insert('user', input);
});import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod4';
import { publicQuery, publicMutation } from '../lib/crpc';
export const list = publicQuery
.input(z.object({ limit: z.number().optional() }))
.output(z.array(z.object({
_id: zid('user'),
_creationTime: z.number(),
name: z.string(),
email: z.string(),
})))
.query(async ({ ctx, input }) => {
return ctx.table('user').take(input.limit ?? 10);
});
export const create = publicMutation
.input(z.object({ name: z.string(), email: z.string() }))
.output(zid('user'))
.mutation(async ({ ctx, input }) => {
return ctx.table('user').insert(input);
});The .output() schema is required for proper TypeScript inference when using staticApi codegen.
5. Set Up Client
import { api } from '@convex/api';
import { meta } from '@convex/meta';
import { createCRPCContext } from 'better-convex/react';
export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext<typeof api>({
api,
meta,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});Wrap your app:
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
ConvexProvider,
ConvexReactClient,
getQueryClientSingleton,
getConvexQueryClientSingleton,
} from 'better-convex/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 }) {
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>
);
}Finally, update your existing src/app/layout.tsx to wrap the app with providers:
import { Providers } from './providers';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}6. Use It
'use client';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
export default function UserPage() {
const crpc = useCRPC();
const { data: users, isPending } = useQuery(
crpc.user.list.queryOptions({ limit: 10 })
);
const createUser = useMutation(crpc.user.create.mutationOptions());
if (isPending) return <div>Loading...</div>;
return (
<div>
<button
onClick={() => createUser.mutate({ name: 'John', email: 'john@example.com' })}
>
Create User
</button>
{users?.map((user) => (
<div key={user._id}>{user.name}</div>
))}
</div>
);
}7. Run the App
Start Next.js and open http://localhost:3000/user:
bun devNext Steps
You have a working Better Convex app. Add more features: