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.

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

Installation

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

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

We'll start by defining tables and relations in one file.

convex/functions/schema.ts
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 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 ORM + cRPC Builder

First, attach the ORM to your context. Create convex/lib/orm.ts:

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:

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

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

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