BETTER-CONVEX

Templates

Copy-paste templates organized by feature.

In this guide, you'll find production-ready templates for every layer of a Better Convex application. Copy the files you need and customize them for your project.

Overview

SectionWhat's Included
Project StructureMonolithic folder layout, import rules
Environment VariablesLocal and cloud configuration
ServerSchema, cRPC builder, types, metadata
Better AuthAuth setup, triggers, HTTP routes
React ClientProviders, QueryClient, cRPC context
Next.js RSCServer utilities, prefetching, hydration
Database UtilitiesORM, triggers, rate limiting
Dev ScriptsReset, init, package.json scripts

Pick only the features you need. Let's start with the project structure.

Project Structure

Monolithic structure with two apps in one repo (simpler HMR than monorepo):

├── src/              # Next.js app (React client)
├── convex/           # Convex app (backend)
│   ├── functions/    # Queries, mutations, actions
│   ├── lib/          # Backend utilities (crpc, orm, triggers)
│   ├── routers/      # HTTP route handlers (health, todos, etc.)
│   └── shared/       # Types shared with client (@convex/*)

Import rules (enforced by Biome):

  • src/ can import from convex/shared/ via @convex/* alias
  • src/ cannot import from convex/functions/ or convex/lib/
  • src/ should not import from convex/* packages (use better-convex/* wrappers)
  • convex/ cannot import from src/
  • convex/shared/ cannot import from convex/lib/ (keeps it client-safe)

Environment Variables

Required environment variables for better-convex:

.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

# Better Auth
NEXT_PUBLIC_SITE_URL=http://localhost:3000

Port 3210 is for WebSocket connections, port 3211 is for HTTP actions (auth routes).

.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

# Better Auth
NEXT_PUBLIC_SITE_URL=http://localhost:3000

TypeScript Config

tsconfig.json

Root TypeScript configuration with path aliases for @convex/* imports.

tsconfig.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "strict": true,
    "strictFunctionTypes": false,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "lib": ["dom", "dom.iterable", "esnext"],
    "target": "es2022",
    "moduleDetection": "force",
    "plugins": [{ "name": "next" }],
    "allowImportingTsExtensions": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "paths": {
      "@/*": ["./src/*"],
      "@convex/*": ["./convex/functions/_generated/*", "./convex/shared/*"]
    },
    "types": ["bun-types"],
    "allowJs": true,
    "incremental": true
  },
  "include": [
    "next-env.d.ts",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts",
    "src/**/*.ts*"
  ],
  "exclude": ["node_modules"]
}

Server

Required files for any better-convex project.

schema.ts

Database schema definition. This example shows Better Auth tables with the admin plugin.

convex/functions/schema.ts
import {
  boolean,
  convexTable,
  defineRelations,
  defineSchema,
  index,
  text,
  timestamp,
} from 'better-convex/orm';

// Better Auth core tables (minimal template)
export const userTable = convexTable(
  'user',
  {
    name: text().notNull(),
    email: text().notNull(),
    emailVerified: boolean().notNull(),
    image: text(),
    createdAt: timestamp().notNull().defaultNow(),
    updatedAt: timestamp().notNull(),
    role: text(),
    banned: boolean(),
    banReason: text(),
    banExpires: timestamp(),
  },
  (t) => [index('email').on(t.email)]
);

export const sessionTable = convexTable(
  'session',
  {
    token: text().notNull(),
    userId: text().references(() => userTable.id, { onDelete: 'cascade' }).notNull(),
    expiresAt: timestamp().notNull(),
    createdAt: timestamp().notNull().defaultNow(),
    updatedAt: timestamp().notNull(),
    ipAddress: text(),
    userAgent: text(),
    impersonatedBy: text(),
  },
  (t) => [index('token').on(t.token), index('userId').on(t.userId)]
);

export const accountTable = convexTable(
  'account',
  {
    accountId: text().notNull(),
    providerId: text().notNull(),
    userId: text().references(() => userTable.id, { onDelete: 'cascade' }).notNull(),
    accessToken: text(),
    refreshToken: text(),
    idToken: text(),
    accessTokenExpiresAt: timestamp(),
    refreshTokenExpiresAt: timestamp(),
    scope: text(),
    password: text(),
    createdAt: timestamp().notNull().defaultNow(),
    updatedAt: timestamp().notNull(),
  },
  (t) => [index('accountId').on(t.accountId), index('userId').on(t.userId)]
);

export const verificationTable = convexTable(
  'verification',
  {
    identifier: text().notNull(),
    value: text().notNull(),
    expiresAt: timestamp().notNull(),
    createdAt: timestamp().notNull().defaultNow(),
    updatedAt: timestamp().notNull(),
  },
  (t) => [index('identifier').on(t.identifier)]
);

export const jwksTable = convexTable('jwks', {
  publicKey: text().notNull(),
  privateKey: text().notNull(),
  createdAt: timestamp().notNull().defaultNow(),
});

export const tables = {
  user: userTable,
  session: sessionTable,
  account: accountTable,
  verification: verificationTable,
  jwks: jwksTable,
};

const schema = defineSchema(tables, { strict: false });
export const relations = defineRelations(tables);
export default schema;

orm.ts

Attach the ORM to your context as ctx.orm (once).

convex/lib/orm.ts
import { createOrm, type GenericOrmCtx } from 'better-convex/orm';

import type { MutationCtx, QueryCtx } from '../functions/_generated/server';
import { relations } from '../functions/schema';

const orm = createOrm({ schema: relations });

export type OrmCtx<Ctx extends QueryCtx | MutationCtx = QueryCtx> =
  GenericOrmCtx<Ctx, typeof relations>;

export type OrmQueryCtx = OrmCtx<QueryCtx>;
export type OrmMutationCtx = OrmCtx<MutationCtx>;

export function withOrm<Ctx extends QueryCtx | MutationCtx>(ctx: Ctx) {
  return { ...ctx, orm: orm.db(ctx) } as OrmCtx<Ctx>;
}

Recommended default for ORM + cRPC: declare createdAt: timestamp().notNull().defaultNow() on tables where you want Date values.

Native (number timestamp) mode:

convex/lib/orm.ts
const orm = createOrm({ schema: relations });

In native mode (no explicit createdAt column), system createdAt remains number.

crpc.ts

The cRPC builder with auth middleware and procedure variants.

convex/lib/crpc.ts
import { getHeaders } from 'better-convex/auth';
import { CRPCError, initCRPC } from 'better-convex/server';
import type { Auth } from 'convex/server';

import { api } from '../functions/_generated/api';
import type { DataModel } from '../functions/_generated/dataModel';
import type { ActionCtx, MutationCtx, QueryCtx } from '../functions/_generated/server';
import { getAuth } from '../functions/auth';
import type { SessionUser } from '../shared/auth-shared';
import { getSessionUser } from './auth/auth-helpers';
import { getEnv } from './get-env';
import { type OrmCtx, withOrm } from './orm';
import { rateLimitGuard } from './rate-limiter';

/** Context with optional auth - user/userId may be null */
export type MaybeAuthCtx<Ctx extends MutationCtx | QueryCtx = QueryCtx> =
  OrmCtx<Ctx> & {
    auth: Auth & Partial<ReturnType<typeof getAuth> & { headers: Headers }>;
    user: SessionUser | null;
    userId: string | null;
  };

/** Context with required auth - user/userId guaranteed */
export type AuthCtx<Ctx extends MutationCtx | QueryCtx = QueryCtx> =
  OrmCtx<Ctx> & {
    auth: Auth & ReturnType<typeof getAuth> & { headers: Headers };
    user: SessionUser;
    userId: string;
  };

/** Context type for authenticated actions */
export type AuthActionCtx = ActionCtx & {
  user: SessionUser;
  userId: string;
};

type Meta = {
  auth?: 'optional' | 'required';
  role?: 'admin';
  rateLimit?: string;
  dev?: boolean;
};

const c = initCRPC
  .dataModel<DataModel>()
  .context({
    query: (ctx) => withOrm(ctx),
    mutation: (ctx) => withOrm(ctx),
  })
  .meta<Meta>()
  .create();

const devMiddleware = c.middleware<object>(({ meta, next, ctx }) => {
  if (meta.dev && getEnv().DEPLOY_ENV === 'production') {
    throw new CRPCError({
      code: 'FORBIDDEN',
      message: 'This function is only available in development',
    });
  }
  return next({ ctx });
});

const rateLimitMiddleware = c.middleware<
  MutationCtx & { user?: Pick<SessionUser, 'id' | 'plan'> | null }
>(async ({ ctx, meta, next }) => {
  await rateLimitGuard({
    ...ctx,
    rateLimitKey: meta.rateLimit ?? 'default',
    user: ctx.user ?? null,
  });
  return next({ ctx });
});

const roleMiddleware = c.middleware<object>(({ ctx, meta, next }) => {
  const user = (ctx as { user?: { isAdmin?: boolean } }).user;
  if (meta.role === 'admin' && !user?.isAdmin) {
    throw new CRPCError({ code: 'FORBIDDEN', message: 'Admin access required' });
  }
  return next({ ctx });
});

function requireAuth<T>(user: T | null): T {
  if (!user) {
    throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
  }
  return user;
}

export const publicQuery = c.query.use(devMiddleware);
export const privateQuery = c.query.internal();

export const optionalAuthQuery = c.query
  .meta({ auth: 'optional' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const user = await getSessionUser(ctx);

    return next({
      ctx: {
        ...ctx,
        auth: user
          ? {
              ...ctx.auth,
              ...getAuth(ctx),
              headers: await getHeaders(ctx, user.session),
            }
          : ctx.auth,
        user,
        userId: user?.id ?? null,
      },
    });
  });

export const authQuery = c.query
  .meta({ auth: 'required' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const user = requireAuth(await getSessionUser(ctx));

    return next({
      ctx: {
        ...ctx,
        auth: {
          ...ctx.auth,
          ...getAuth(ctx),
          headers: await getHeaders(ctx, user.session),
        },
        user,
        userId: user.id,
      },
    });
  })
  .use(roleMiddleware);

export const publicMutation = c.mutation.use(devMiddleware).use(rateLimitMiddleware);
export const privateMutation = c.mutation.internal();

export const optionalAuthMutation = c.mutation
  .meta({ auth: 'optional' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const user = await getSessionUser(ctx);

    return next({
      ctx: {
        ...ctx,
        auth: user
          ? {
              ...ctx.auth,
              ...getAuth(ctx),
              headers: await getHeaders(ctx, user.session),
            }
          : ctx.auth,
        user,
        userId: user?.id ?? null,
      },
    });
  })
  .use(rateLimitMiddleware);

export const authMutation = c.mutation
  .meta({ auth: 'required' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const user = requireAuth(await getSessionUser(ctx));

    return next({
      ctx: {
        ...ctx,
        auth: {
          ...ctx.auth,
          ...getAuth(ctx),
          headers: await getHeaders(ctx, user.session),
        },
        user,
        userId: user.id,
      },
    });
  })
  .use(roleMiddleware)
  .use(rateLimitMiddleware);

export const publicAction = c.action.use(devMiddleware);
export const privateAction = c.action.internal();

export const authAction = c.action
  .meta({ auth: 'required' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const user = requireAuth(await ctx.runQuery(api.user.getSessionUser));

    return next({ ctx: { ...ctx, user, userId: user.id } });
  });

export const publicRoute = c.httpAction;

export const authRoute = c.httpAction.use(async ({ ctx, next }) => {
  const identity = await ctx.auth.getUserIdentity();

  if (!identity) {
    throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
  }

  return next({
    ctx: {
      ...ctx,
      userId: identity.subject,
      user: {
        id: identity.subject,
        email: identity.email,
        name: identity.name,
      },
    },
  });
});

export const optionalAuthRoute = c.httpAction.use(async ({ ctx, next }) => {
  const identity = await ctx.auth.getUserIdentity();

  return next({
    ctx: {
      ...ctx,
      userId: identity ? identity.subject : null,
      user: identity
        ? {
            id: identity.subject,
            email: identity.email,
            name: identity.name,
          }
        : null,
    },
  });
});

export const router = c.router;

types.ts

Type utilities for API inference and ORM model types.

convex/shared/types.ts
import type { InferInsertModel, InferSelectModel } from 'better-convex/orm';
import type {
  inferApiInputs,
  inferApiOutputs,
  WithHttpRouter,
} from 'better-convex/server';

import type { api } from '../functions/_generated/api';
// biome-ignore lint/style/noRestrictedImports: type
import type { appRouter } from '../functions/http';
// biome-ignore lint/style/noRestrictedImports: type
import type { tables } from '../functions/schema';

export type TableName = keyof typeof tables;
export type Select<T extends TableName> = InferSelectModel<(typeof tables)[T]>;
export type Insert<T extends TableName> = InferInsertModel<(typeof tables)[T]>;

export type Api = WithHttpRouter<typeof api, typeof appRouter>;

export type ApiInputs = inferApiInputs<Api>;
export type ApiOutputs = inferApiOutputs<Api>;

meta.ts

Generated by the CLI. Contains procedure metadata for auth-aware query handling.

bunx better-convex dev

Better Auth

Optional - only needed if using Better Auth for authentication.

auth.ts

Better Auth client setup with Convex adapter.

convex/functions/auth.ts
import { type BetterAuthOptions, betterAuth } from 'better-auth';
import { admin } from 'better-auth/plugins';
import {
  type AuthFunctions,
  convex,
  createApi,
  createClient,
} from 'better-convex/auth';

import { type OrmMutationCtx, withOrm } from '../lib/orm';
import { internal } from './_generated/api';
import type { DataModel } from './_generated/dataModel';
import type { ActionCtx, MutationCtx, QueryCtx } from './_generated/server';
import authConfig from './auth.config';
import schema from './schema';

type GenericCtx = QueryCtx | MutationCtx | ActionCtx;

const authFunctions: AuthFunctions = internal.auth;

export const authClient = createClient<
  DataModel,
  typeof schema,
  OrmMutationCtx
>({
  authFunctions,
  schema,
  context: withOrm,
});

const getAuthOptions = (ctx: GenericCtx) =>
  ({
    baseURL: process.env.SITE_URL!,
    database: authClient.adapter(ctx, getAuthOptions),
    plugins: [
      admin(),
      convex({
        authConfig,
        jwks: process.env.JWKS,
      }),
    ],
    session: {
      expiresIn: 60 * 60 * 24 * 30,
      updateAge: 60 * 60 * 24 * 15,
    },
    trustedOrigins: [process.env.SITE_URL ?? 'http://localhost:3000'],
  }) satisfies BetterAuthOptions;

export const getAuth = (ctx: GenericCtx) => betterAuth(getAuthOptions(ctx));

export const {
  beforeCreate,
  beforeDelete,
  beforeUpdate,
  onCreate,
  onDelete,
  onUpdate,
} = authClient.triggersApi();

export const {
  create,
  deleteMany,
  deleteOne,
  findMany,
  findOne,
  updateMany,
  updateOne,
  getLatestJwks,
  rotateKeys,
} = createApi(schema, getAuth, {
  context: withOrm,
  skipValidation: true,
});

// biome-ignore lint/suspicious/noExplicitAny: Required for CLI
export const auth = betterAuth(getAuthOptions({} as any));

See Auth Server documentation for detailed configuration.

auth.config.ts

Auth configuration provider for Convex.

convex/functions/auth.config.ts
import { getAuthConfigProvider } from 'better-convex/auth-config';
import type { AuthConfig } from 'convex/server';

export default {
  providers: [getAuthConfigProvider({ jwks: process.env.JWKS })],
} satisfies AuthConfig;

http-polyfills.ts

Convex's runtime environment doesn't include MessageChannel, which Better Auth's HTTP handling requires.

convex/lib/http-polyfills.ts
// polyfill MessageChannel without using node:events
if (typeof MessageChannel === 'undefined') {
  class MockMessagePort {
    onmessage: ((ev: MessageEvent) => void) | undefined;
    onmessageerror: ((ev: MessageEvent) => void) | undefined;

    addEventListener() {}
    close() {}

    dispatchEvent(_event: Event): boolean {
      return false;
    }

    postMessage(_message: unknown, _transfer: Transferable[] = []) {}
    removeEventListener() {}
    start() {}
  }

  class MockMessageChannel {
    port1: MockMessagePort;
    port2: MockMessagePort;

    constructor() {
      this.port1 = new MockMessagePort();
      this.port2 = new MockMessagePort();
    }
  }

  globalThis.MessageChannel =
    MockMessageChannel as unknown as typeof MessageChannel;
}

Import this at the top of your HTTP file (before other imports).

http.ts

HTTP routes for Better Auth.

convex/functions/http.ts
import '../lib/http-polyfills';
import { authMiddleware } from 'better-convex/auth';
import { createHttpRouter } from 'better-convex/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { router } from '../lib/crpc';
// Import your routers from convex/routers/
import { getAuth } from './auth';

const app = new Hono();

// CORS for API routes (auth + cRPC)
app.use(
  '/api/*',
  cors({
    origin: process.env.SITE_URL!,
    allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
    exposeHeaders: ['Set-Better-Auth-Cookie'],
    credentials: true,
  })
);

// Better Auth middleware
app.use(authMiddleware(getAuth));

// HTTP API router (tRPC-style)
export const appRouter = router({
  // Add your routers here
});

export default createHttpRouter(app, appRouter);
convex/functions/http.ts
import '../lib/http-polyfills';
import { registerRoutes } from 'better-convex/auth';
import { httpRouter } from 'convex/server';
import { getAuth } from './auth';

const http = httpRouter();

registerRoutes(http, getAuth, {
  cors: {
    allowedOrigins: [process.env.SITE_URL!],
  },
});

export default http;

See HTTP Router documentation for route examples.

user.ts

User queries including getSessionUser for auth state caching.

convex/functions/user.ts
import { z } from 'zod';
import { optionalAuthQuery, publicQuery } from '../lib/crpc';

/** Get session user - used by AuthSync and authAction */
export const getSessionUser = optionalAuthQuery
  .output(
    z.union([
      z.object({
        id: z.string(),
        image: z.string().nullish(),
        isAdmin: z.boolean(),
        name: z.string().optional(),
        plan: z.string().optional(),
      }),
      z.null(),
    ])
  )
  .query(async ({ ctx }) => {
    const { user } = ctx;
    if (!user) {
      return null;
    }

    return {
      id: user.id,
      image: user.image,
      isAdmin: user.isAdmin ?? false,
      name: user.name,
      plan: user.plan,
    };
  });

/** Check if user is authenticated */
export const getIsAuthenticated = publicQuery
  .output(z.boolean())
  .query(async ({ ctx }) => !!(await ctx.auth.getUserIdentity()));

auth-shared.ts

Session user type for auth context. Customize based on your user table fields.

convex/shared/auth-shared.ts
import type { Doc } from '../functions/_generated/dataModel';
// biome-ignore lint/style/noRestrictedImports: types
import type { getAuth } from '../functions/auth';
import type { Select } from './types';

export type Auth = ReturnType<typeof getAuth>;

export type SessionUser = Select<'user'> & {
  isAdmin: boolean;
  // Native Better Auth session document (used for auth header/session plumbing).
  session: Doc<'session'>;
  impersonatedBy?: string | null;
  plan?: 'premium' | 'team';
};

auth-client.ts

Better Auth client for React with plugins.

src/lib/convex/auth-client.ts
import type { Auth } from '@convex/auth-shared';
import { adminClient, inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import { convexClient } from 'better-convex/auth-client';
import { createAuthMutations } from 'better-convex/react';

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_SITE_URL!,
  sessionOptions: {
    // Disable session polling on tab focus (saves ~500ms HTTP call per focus)
    refetchOnWindowFocus: false,
  },
  plugins: [inferAdditionalFields<Auth>(), adminClient(), convexClient()],
});

export const { useActiveOrganization, useListOrganizations } = authClient;

export const {
  useSignOutMutationOptions,
  useSignInSocialMutationOptions,
  useSignInMutationOptions,
  useSignUpMutationOptions,
} = createAuthMutations(authClient);

React Client

Optional - needed for React apps using TanStack Query integration.

convex-provider.tsx

Convex provider with TanStack Query integration.

src/lib/convex/convex-provider.tsx
'use client';

import { QueryClientProvider as TanstackQueryClientProvider } from '@tanstack/react-query';
import { ConvexAuthProvider } from 'better-convex/auth-client';
import {
  ConvexReactClient,
  getConvexQueryClientSingleton,
  getQueryClientSingleton,
  useAuthStore,
} from 'better-convex/react';
import { useRouter } from 'next/navigation';
import type { ReactNode } from 'react';
import { toast } from 'sonner';

import { authClient } from '@/lib/convex/auth-client';
import { CRPCProvider } from '@/lib/convex/crpc';
import { createQueryClient } from '@/lib/convex/query-client';

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export function BetterConvexProvider({
  children,
  token,
}: {
  children: ReactNode;
  token?: string;
}) {
  const router = useRouter();

  return (
    <ConvexAuthProvider
      authClient={authClient}
      client={convex}
      initialToken={token}
      onMutationUnauthorized={() => {
        router.push('/login');
      }}
      onQueryUnauthorized={({ queryName }) => {
        if (process.env.NODE_ENV === 'development') {
          toast.error(`${queryName} requires authentication`);
        } else {
          router.push('/login');
        }
      }}
    >
      <QueryClientProvider>{children}</QueryClientProvider>
    </ConvexAuthProvider>
  );
}

function QueryClientProvider({ children }: { children: ReactNode }) {
  const authStore = useAuthStore();

  const queryClient = getQueryClientSingleton(createQueryClient);
  const convexQueryClient = getConvexQueryClientSingleton({
    authStore,
    convex,
    queryClient,
  });

  return (
    <TanstackQueryClientProvider client={queryClient}>
      <CRPCProvider convexClient={convex} convexQueryClient={convexQueryClient}>
        {children}
      </CRPCProvider>
    </TanstackQueryClientProvider>
  );
}

query-client.ts

QueryClient configuration with hydration and error handling.

src/lib/convex/query-client.ts
import {
  type DefaultOptions,
  defaultShouldDehydrateQuery,
  QueryCache,
  QueryClient,
} from '@tanstack/react-query';
import { isCRPCClientError, isCRPCError } from 'better-convex/crpc';
import { toast } from 'sonner';
import SuperJSON from 'superjson';

/** Shared hydration config for SSR data transfer (used by client + server) */
export const hydrationConfig: Pick<DefaultOptions, 'dehydrate' | 'hydrate'> = {
  dehydrate: {
    serializeData: SuperJSON.serialize,
    shouldDehydrateQuery: (query) =>
      defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
    shouldRedactErrors: () => false,
  },
  hydrate: {
    deserializeData: SuperJSON.deserialize,
  },
};

/** Create QueryClient for client-side with error handling */
export function createQueryClient() {
  return new QueryClient({
    queryCache: new QueryCache({
      onError: (error) => {
        if (isCRPCClientError(error)) {
          console.warn(`[CRPC] ${error.code}:`, error.functionName);
        }
      },
    }),
    defaultOptions: {
      ...hydrationConfig,
      mutations: {
        onError: (err, _variables, _context, mutation) => {
          const error = err as Error & { data?: { message?: string } };
          const meta = mutation.meta as
            | { errorMessage?: string; skipErrorToast?: boolean }
            | undefined;

          if (meta?.skipErrorToast) return;

          toast.error(
            error.data?.message ||
              meta?.errorMessage ||
              error.message ||
              'Something went wrong'
          );
        },
      },
      queries: {
        retry: (failureCount, error) => {
          if (isCRPCError(error)) return false;

          const message =
            error instanceof Error ? error.message : String(error);

          if (message.includes('timed out') && failureCount < 3) {
            console.warn(
              `[QueryClient] Retrying timed out query (attempt ${failureCount + 1}/3)`
            );
            return true;
          }

          return failureCount < 3;
        },
        retryDelay: (attemptIndex) =>
          Math.min(2000 * 2 ** attemptIndex, 30_000),
      },
    },
  });
}

crpc.tsx

CRPC context provider for React with HTTP router support.

src/lib/convex/crpc.tsx
import { api } from '@convex/api';
import { meta } from '@convex/meta';
import type { Api } from '@convex/types';
import { createCRPCContext } from 'better-convex/react';

export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext<Api>({
  api,
  meta,
  convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});

Next.js RSC

Optional - needed for React Server Components prefetching.

server.ts

Server-side utilities for Next.js RSC.

src/lib/convex/server.ts
import { api } from '@convex/api';
import { meta } from '@convex/meta';
import { convexBetterAuth } from 'better-convex/auth-nextjs';

export const { createContext, createCaller, handler } = convexBetterAuth({
  api,
  convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
  meta,
});

rsc.tsx

React Server Components utilities with prefetching.

src/lib/convex/rsc.tsx
import 'server-only';

import { api } from '@convex/api';
import { meta } from '@convex/meta';
import type { Api } from '@convex/types';
import type { FetchQueryOptions } from '@tanstack/react-query';
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query';
import {
  createServerCRPCProxy,
  getServerQueryClientOptions,
} from 'better-convex/rsc';
import { headers } from 'next/headers';
import { cache } from 'react';

import { env } from '@/env';

import { hydrationConfig } from './query-client';
import { createCaller, createContext } from './server';

// RSC context factory - wraps createContext with cache() and next/headers
const createRSCContext = cache(async () =>
  createContext({ headers: await headers() })
);

export const caller = createCaller(createRSCContext);

export const crpc = createServerCRPCProxy<Api>({ api, meta });

function createServerQueryClient() {
  return new QueryClient({
    defaultOptions: {
      ...hydrationConfig,
      ...getServerQueryClientOptions({
        getToken: caller.getToken,
        convexSiteUrl: env.NEXT_PUBLIC_CONVEX_SITE_URL,
      }),
    },
  });
}

export const getQueryClient = cache(createServerQueryClient);

export function prefetch<T extends { queryKey: readonly unknown[] }>(
  queryOptions: T
): void {
  void getQueryClient().prefetchQuery(queryOptions);
}

export function preloadQuery<
  TQueryFnData = unknown,
  TError = Error,
  TData = TQueryFnData,
  TQueryKey extends readonly unknown[] = readonly unknown[],
>(
  options: FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>
): Promise<TData> {
  return getQueryClient().fetchQuery(options);
}

export function HydrateClient({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient();
  const dehydratedState = dehydrate(queryClient);

  return (
    <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
  );
}

Database Utilities

Optional utilities for triggers and rate limiting.

schema triggers

Define side effects directly in convex/functions/schema.ts.

convex/functions/schema.ts
import { convexTable, onChange } from 'better-convex/orm';
import { aggregatePostCount } from './aggregates';

export const postsTable = convexTable('posts', {/* columns */}, () => [
  aggregatePostCount.trigger(),
  onChange(async (_ctx, change) => {
    console.log('Post changed:', change.operation, change.id);
  }),
]);

See Triggers documentation for patterns.

rate-limiter.ts

Rate limiting configuration with tiered access.

convex/lib/rate-limiter.ts
import { MINUTE, RateLimiter } from '@convex-dev/rate-limiter';
import { CRPCError } from 'better-convex/server';
import { components } from '../functions/_generated/api';
import type { ActionCtx, MutationCtx } from '../functions/_generated/server';
import type { SessionUser } from '../shared/auth-shared';

const rateLimitConfig = {
  'default:free': { kind: 'fixed window', period: MINUTE, rate: 60 },
  'default:premium': { kind: 'fixed window', period: MINUTE, rate: 200 },
  'default:public': { kind: 'fixed window', period: MINUTE, rate: 30 },

  // Example endpoint-specific overrides
  'todo/create:free': { kind: 'fixed window', period: MINUTE, rate: 20 },
  'todo/create:premium': { kind: 'fixed window', period: MINUTE, rate: 60 },
} as const;

export const rateLimiter = new RateLimiter(
  components.rateLimiter,
  rateLimitConfig
);

export function getRateLimitKey(
  baseKey: string,
  tier: 'free' | 'premium' | 'public'
): string {
  const specificKey = `${baseKey}:${tier}`;

  if (specificKey in rateLimitConfig) {
    return specificKey;
  }

  return `default:${tier}`;
}

export function getUserTier(
  user: { isAdmin?: boolean; plan?: SessionUser['plan'] } | null
): 'free' | 'premium' | 'public' {
  if (!user) return 'public';
  if (user.isAdmin) return 'premium';
  if (user.plan) return 'premium';
  return 'free';
}

export async function rateLimitGuard(
  ctx: (ActionCtx | MutationCtx) & {
    rateLimitKey: string;
    user: Pick<SessionUser, 'id' | 'plan'> | null;
  }
) {
  const tier = getUserTier(ctx.user);
  const limitKey = getRateLimitKey(ctx.rateLimitKey, tier);
  const identifier = ctx.user?.id ?? 'anonymous';

  const status = await rateLimiter.limit(ctx, limitKey, {
    key: identifier,
  });

  if (!status.ok) {
    throw new CRPCError({
      code: 'TOO_MANY_REQUESTS',
      message: 'Rate limit exceeded. Please try again later.',
    });
  }
}

See Rate Limiting documentation for setup.


Dev Scripts

Optional scripts for development workflow.

reset.ts

Development-only database reset.

convex/functions/reset.ts
/** biome-ignore-all lint/suspicious/noExplicitAny: dev */
import { eq } from 'better-convex/orm';
import { CRPCError } from 'better-convex/server';
import { z } from 'zod';
import { privateAction, privateMutation } from '../lib/crpc';
import { getEnv } from '../lib/get-env';
import { internal } from './_generated/api';
import type { TableNames } from './_generated/dataModel';
import schema, { tables } from './schema';

const DELETE_BATCH_SIZE = 64;
const excludedTables = new Set<TableNames>();

const assertDevOnly = () => {
  if (getEnv().DEPLOY_ENV === 'production') {
    throw new CRPCError({
      code: 'FORBIDDEN',
      message: 'This function is only available in development',
    });
  }
};

export const reset = privateAction.output(z.null()).action(async ({ ctx }) => {
  assertDevOnly();

  for (const tableName of Object.keys(schema.tables)) {
    if (excludedTables.has(tableName as TableNames)) {
      continue;
    }

    await ctx.scheduler.runAfter(0, internal.reset.deletePage, {
      cursor: null,
      tableName,
    });
  }

  return null;
});

export const deletePage = privateMutation
  .input(
    z.object({
      cursor: z.union([z.string(), z.null()]),
      tableName: z.string(),
    })
  )
  .output(z.null())
  .mutation(async ({ ctx, input }) => {
    assertDevOnly();

    const table = (tables as Record<string, any>)[input.tableName];
    if (!table) {
      throw new CRPCError({
        code: 'BAD_REQUEST',
        message: `Unknown table: ${input.tableName}`,
      });
    }

    const query = (ctx.orm.query as Record<string, any>)[input.tableName];
    if (!query || typeof query.findMany !== 'function') {
      throw new CRPCError({
        code: 'BAD_REQUEST',
        message: `Unknown query table: ${input.tableName}`,
      });
    }

    const results = await query.findMany({
      cursor: input.cursor,
      limit: DELETE_BATCH_SIZE,
    });

    for (const row of results.page) {
      try {
        await ctx.orm.delete(table).where(eq(table.id, (row as any).id));
      } catch {
        // Document might have been deleted by a trigger or concurrent process
      }
    }

    if (!results.isDone) {
      await ctx.scheduler.runAfter(0, internal.reset.deletePage, {
        cursor: results.continueCursor,
        tableName: input.tableName,
      });
    }

    return null;
  });

init.ts

Development initialization that runs on convex dev --run init.

convex/functions/init.ts
import { z } from 'zod';
import { createUser } from '../lib/auth/auth-helpers';
import { privateMutation } from '../lib/crpc';
import { getEnv } from '../lib/get-env';
import { internal } from './_generated/api';

/**
 * Initialize the database on startup. This runs with: convex dev --run init
 */
export default privateMutation
  .meta({ dev: true })
  .output(z.null())
  .mutation(async ({ ctx }) => {
    const env = getEnv();
    const adminEmails = env.ADMIN;

    if (!adminEmails || adminEmails.length === 0) {
      return null;
    }

    let isFirstInit = true;

    for (const adminEmail of adminEmails) {
      const existingUser = await ctx.orm.query.user.findFirst({
        where: { email: adminEmail },
      });

      if (existingUser) {
        isFirstInit = false;
        continue;
      }

      await createUser(ctx, {
        email: adminEmail,
        name: 'Admin',
        role: 'admin',
      });
    }

    if (isFirstInit && getEnv().DEPLOY_ENV === 'development') {
      await ctx.runMutation(internal.seed.seed, {});
    }

    return null;
  });

package.json scripts

Common scripts for development workflow:

package.json
{
  "scripts": {
    "convex:dev": "convex dev --until-success --run init && better-convex dev",
    "reset": "convex run reset:reset && sleep 5 && convex run init",
    "seed": "convex run seed:seed",
    "sync:jwks": "convex run auth:getLatestJwks | convex env set JWKS",
    "sync:rotate": "convex run auth:rotateKeys | convex env set JWKS"
  }
}
ScriptDescription
convex:devInitialize DB then run Convex dev with meta generation
resetClear all tables then reinitialize
seedRun seed function manually
sync:jwksSync JWKS from Convex to env
sync:rotateRotate keys and update JWKS

Biome

Import boundary enforcement with Ultracite:

biome.jsonc
{
  "extends": ["ultracite/core", "ultracite/react", "ultracite/next"],
  "overrides": [
    {
      // src/ cannot import from convex/* packages directly
      "includes": ["src/**/*.ts*"],
      "linter": {
        "rules": {
          "style": {
            "noRestrictedImports": {
              "level": "error",
              "options": {
                "paths": {
                  "convex/values": {
                    "importNames": ["ConvexError"],
                    "message": "Use CRPCError from 'better-convex/crpc' instead."
                  },
                  "convex/react": "Use useCRPC from '@/lib/convex/crpc' instead.",
                  "convex/nextjs": "Use caller from '@/lib/convex/rsc' instead."
                },
                "patterns": [{
                  "group": ["**/../convex/**"],
                  "message": "Use @convex/* alias instead of relative convex imports."
                }]
              }
            }
          }
        }
      }
    },
    {
      // convex/ cannot import from src/
      "includes": ["convex/**/*.ts*"],
      "linter": {
        "rules": {
          "style": {
            "noRestrictedImports": {
              "level": "error",
              "options": {
                "patterns": [{
                  "group": ["@/*", "**/src/**"],
                  "message": "Convex files cannot import from src/."
                }]
              }
            }
          }
        }
      }
    },
    {
      // convex/shared/ is client-importable, so restrict its imports
      "includes": ["convex/shared/**/*.ts*"],
      "linter": {
        "rules": {
          "style": {
            "noRestrictedImports": {
              "level": "error",
              "options": {
                "patterns": [{
                  "group": ["**/convex/lib/**"],
                  "message": "convex/shared cannot import from convex/lib."
                }]
              }
            }
          }
        }
      }
    }
  ]
}

Next Steps

On this page