BETTER-CONVEX

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-app

Installation

bun add convex better-convex convex-helpers zod @tanstack/react-query
bun add convex better-convex convex-helpers zod @tanstack/react-query convex-ents

Configure Folder Structure

Better Convex uses a specific folder structure. Create convex.json:

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 metadata

Create these folders:

mkdir -p convex/functions convex/lib src/lib/convex

Next, add these settings to your existing tsconfig.json:

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

convex/functions/schema.ts
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']),
});
convex/functions/schema.ts
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 dev

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

.env.local
# WebSocket API (port 3210)
NEXT_PUBLIC_CONVEX_URL=http://localhost:3210

# HTTP routes (port 3211)
NEXT_PUBLIC_CONVEX_SITE_URL=http://localhost:3211
.env.local
# 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.site

3. Create cRPC Builder

convex/lib/crpc.ts
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:

convex/lib/ents.ts
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:

convex/lib/crpc.ts
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

convex/functions/user.ts
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);
  });
convex/functions/user.ts
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

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

src/app/providers.tsx
'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:

src/app/layout.tsx
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

src/app/user/page.tsx
'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 dev

Next Steps

You have a working Better Convex app. Add more features:

On this page