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.
Docs use the ORM (ctx.orm) everywhere. If you're migrating from native Convex, see
/docs/migrations/db.
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 zod @tanstack/react-queryConfigure 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
We'll start by defining tables and relations in one file.
import {
boolean,
convexTable,
defineRelations,
defineSchema,
id,
index,
text,
} from 'better-convex/orm';
export const user = convexTable('user', {
name: text().notNull(),
email: text().notNull(),
});
export const posts = convexTable(
'posts',
{
title: text().notNull(),
published: boolean(),
userId: id('user').notNull(),
},
(t) => [index('by_user').on(t.userId)]
);
const tables = { user, posts };
export default defineSchema(tables, { strict: false });
export const relations = defineRelations(tables, (r) => ({
user: { posts: r.many.posts() },
posts: { author: r.one.user({ from: r.posts.userId, to: r.user.id }) },
}));Docs use strict: false to keep snippets short. Keep strict default and add indexes in real apps.
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 ORM + cRPC Builder
First, attach the ORM to your context. Create convex/lib/orm.ts:
import { createOrm } from 'better-convex/orm';
import type { MutationCtx, QueryCtx } from '../functions/_generated/server';
import { relations } from '../functions/schema';
export const orm = createOrm({ schema: relations });
export const withOrm = <Ctx extends QueryCtx | MutationCtx>(ctx: Ctx) => ({
...ctx,
orm: orm.db(ctx),
});Now create the cRPC builder in convex/lib/crpc.ts:
import { initCRPC } from 'better-convex/server';
import type {
ActionCtx,
MutationCtx,
QueryCtx,
} from '../functions/_generated/server';
import type { DataModel } from '../functions/_generated/dataModel';
import { withOrm } from './orm';
const c = initCRPC
.dataModel<DataModel>()
.context({
query: (ctx) => withOrm(ctx),
mutation: (ctx) => withOrm(ctx),
})
.create();
export const publicQuery = c.query;
export const publicMutation = c.mutation;4. Define Procedures
import { z } from 'zod';
import { publicQuery, publicMutation } from '../lib/crpc';
import { user } from './schema';
export const list = publicQuery
.input(z.object({ limit: z.number().optional() }))
.output(
z.array(
z.object({
id: z.string(),
createdAt: z.date(),
name: z.string(),
email: z.string(),
posts: z.array(
z.object({
id: z.string(),
createdAt: z.date(),
title: z.string(),
published: z.boolean().nullable(),
})
),
})
)
)
.query(async ({ ctx, input }) => {
return ctx.orm.query.user.findMany({
limit: input.limit ?? 10,
with: {
posts: {
limit: 5,
orderBy: { createdAt: 'desc' },
columns: {
id: true,
createdAt: true,
title: true,
published: true,
},
},
},
});
});
export const create = publicMutation
.input(z.object({ name: z.string(), email: z.string() }))
.output(z.string())
.mutation(async ({ ctx, input }) => {
const [row] = await ctx.orm
.insert(user)
.values(input)
.returning({ id: user.id });
return row.id;
});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: