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 UtilitiesEnts, 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, ents, 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
{
  "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/*"]
    },
    "allowJs": true,
    "incremental": true
  },
  "include": [
    "next-env.d.ts",
    ".next/types/**/*.ts",
    "src/**/*.ts*",
    "convex/**/*.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 { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  user: defineTable({
    name: v.string(),
    email: v.string(),
    emailVerified: v.boolean(),
    image: v.optional(v.string()),
    createdAt: v.number(),
    updatedAt: v.number(),
    // Admin plugin
    role: v.optional(v.string()),
    banned: v.optional(v.boolean()),
    banReason: v.optional(v.string()),
    banExpires: v.optional(v.number()),
  })
    .index('email', ['email']),

  session: defineTable({
    token: v.string(),
    expiresAt: v.number(),
    createdAt: v.number(),
    updatedAt: v.number(),
    ipAddress: v.optional(v.string()),
    userAgent: v.optional(v.string()),
    userId: v.string(),
    // Admin plugin
    impersonatedBy: v.optional(v.string()),
  })
    .index('token', ['token'])
    .index('userId', ['userId']),

  account: defineTable({
    accountId: v.string(),
    providerId: v.string(),
    userId: v.string(),
    accessToken: v.optional(v.string()),
    refreshToken: v.optional(v.string()),
    idToken: v.optional(v.string()),
    accessTokenExpiresAt: v.optional(v.number()),
    refreshTokenExpiresAt: v.optional(v.number()),
    scope: v.optional(v.string()),
    password: v.optional(v.string()),
    createdAt: v.number(),
    updatedAt: v.number(),
  })
    .index('accountId', ['accountId'])
    .index('userId', ['userId']),

  verification: defineTable({
    identifier: v.string(),
    value: v.string(),
    expiresAt: v.number(),
    createdAt: v.optional(v.number()),
    updatedAt: v.optional(v.number()),
  })
    .index('identifier', ['identifier']),

  jwks: defineTable({
    publicKey: v.string(),
    privateKey: v.string(),
    createdAt: v.number(),
  }),

  // Your other tables
});
convex/functions/schema.ts
import { v } from 'convex/values';
import { defineEnt, defineEntSchema, getEntDefinitions } from 'convex-ents';

const schema = defineEntSchema({
  user: defineEnt({
    name: v.string(),
    email: v.string(),
    emailVerified: v.boolean(),
    image: v.optional(v.string()),
    createdAt: v.number(),
    updatedAt: v.number(),
    // Admin plugin
    role: v.optional(v.string()),
    banned: v.optional(v.boolean()),
    banReason: v.optional(v.string()),
    banExpires: v.optional(v.number()),
  })
    .index('email', ['email'])
    .edges('sessions', { to: 'session', ref: 'userId' })
    .edges('accounts', { to: 'account', ref: 'userId' }),

  session: defineEnt({
    token: v.string(),
    expiresAt: v.number(),
    createdAt: v.number(),
    updatedAt: v.number(),
    ipAddress: v.optional(v.string()),
    userAgent: v.optional(v.string()),
    // Admin plugin
    impersonatedBy: v.optional(v.string()),
  })
    .index('token', ['token'])
    .edge('user', { to: 'user', field: 'userId' }),

  account: defineEnt({
    accountId: v.string(),
    providerId: v.string(),
    accessToken: v.optional(v.string()),
    refreshToken: v.optional(v.string()),
    idToken: v.optional(v.string()),
    accessTokenExpiresAt: v.optional(v.number()),
    refreshTokenExpiresAt: v.optional(v.number()),
    scope: v.optional(v.string()),
    password: v.optional(v.string()),
    createdAt: v.number(),
    updatedAt: v.number(),
  })
    .index('accountId', ['accountId'])
    .edge('user', { to: 'user', field: 'userId' }),

  verification: defineEnt({
    identifier: v.string(),
    value: v.string(),
    expiresAt: v.number(),
    createdAt: v.optional(v.number()),
    updatedAt: v.optional(v.number()),
  })
    .index('identifier', ['identifier']),

  jwks: defineEnt({
    publicKey: v.string(),
    privateKey: v.string(),
    createdAt: v.number(),
  }),

  // Your other tables
});

export default schema;
export const entDefinitions = getEntDefinitions(schema);

crpc.ts

The cRPC builder with auth middleware and procedure variants.

convex/lib/crpc.ts
import { getHeaders, getSession } from 'better-convex/auth';
import { CRPCError, initCRPC } from 'better-convex/server';
import type { Auth } from 'convex/server';
import {
  customCtx,
  customMutation,
} from 'convex-helpers/server/customFunctions';
import { api } from '../_generated/api';
import type { DataModel, Doc, Id } from '../_generated/dataModel';
import type { ActionCtx, MutationCtx, QueryCtx } from '../_generated/server';
import {
  action,
  httpAction,
  internalAction,
  internalMutation,
  internalQuery,
  mutation,
  query,
} from '../_generated/server';
import { getAuth } from './auth';
import { registerTriggers } from './triggers';
import { rateLimitGuard } from './rate-limiter';

// =============================================================================
// Types
// =============================================================================

export type GenericCtx = QueryCtx | MutationCtx | ActionCtx;

type SessionUser = {
  id: Id<'user'>;
  plan?: 'premium' | null;
  isAdmin?: boolean;
  // Add your user fields here
};

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

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

/** Context type for authenticated actions */
export type AuthActionCtx = ActionCtx & {
  user: SessionUser;
  userId: Id<'user'>;
};

// =============================================================================
// Setup
// =============================================================================

const triggers = registerTriggers();

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

const c = initCRPC
  .dataModel<DataModel>()
  .meta<Meta>()
  .create({
    query,
    internalQuery,
    // biome-ignore lint/suspicious/noExplicitAny: convex internals
    mutation: (handler: any) =>
      mutation({
        ...handler,
        handler: async (ctx: MutationCtx, args: unknown) => {
          const wrappedCtx = triggers.wrapDB(ctx);
          return handler.handler(wrappedCtx, args);
        },
      }),
    // biome-ignore lint/suspicious/noExplicitAny: convex internals
    internalMutation: (handler: any) =>
      internalMutation({
        ...handler,
        handler: async (ctx: MutationCtx, args: unknown) => {
          const wrappedCtx = triggers.wrapDB(ctx);
          return handler.handler(wrappedCtx, args);
        },
      }),
    action,
    internalAction,
    httpAction,
  });

// =============================================================================
// Middleware
// =============================================================================

/** Dev mode middleware - throws in production if meta.dev: true */
const devMiddleware = c.middleware<object>(({ meta, next, ctx }) => {
  if (meta.dev && process.env.DEPLOY_ENV === 'production') {
    throw new CRPCError({
      code: 'FORBIDDEN',
      message: 'This function is only available in development',
    });
  }
  return next({ ctx });
});

/** Rate limit middleware - applies rate limiting based on meta.rateLimit and user tier */
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 });
});

/** Role middleware - checks admin role from meta after auth middleware */
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 });
});

// =============================================================================
// Query Procedures
// =============================================================================

/** Public query - no auth required, supports dev: true in meta */
export const publicQuery = c.query.use(devMiddleware);

/** Private query - only callable from other Convex functions */
export const privateQuery = c.query.internal();

/** Optional auth query - ctx.user may be null, supports dev: true in meta */
export const optionalAuthQuery = c.query
  .meta({ auth: 'optional' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const session = await getSession(ctx);
    if (!session) {
      return next({ ctx: { ...ctx, user: null, userId: null } });
    }

    const user = (await ctx.db.get(session.userId))!;

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

/** Auth query - ctx.user required, supports role: 'admin' and dev: true in meta */
export const authQuery = c.query
  .meta({ auth: 'required' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const session = await getSession(ctx);
    if (!session) {
      throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
    }

    const user = (await ctx.db.get(session.userId))!;

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

// =============================================================================
// Mutation Procedures
// =============================================================================

/** Public mutation - no auth required, rate limited, supports dev: true in meta */
export const publicMutation = c.mutation
  .use(devMiddleware)
  .use(rateLimitMiddleware);

/** Private mutation - only callable from other Convex functions */
export const privateMutation = c.mutation.internal();

/** Optional auth mutation - ctx.user may be null, rate limited, supports dev: true */
export const optionalAuthMutation = c.mutation
  .meta({ auth: 'optional' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const session = await getSession(ctx);
    if (!session) {
      return next({ ctx: { ...ctx, user: null, userId: null } });
    }

    const user = (await ctx.db.get(session.userId))!;

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

/** Auth mutation - ctx.user required, rate limited, supports role: 'admin' and dev: true */
export const authMutation = c.mutation
  .meta({ auth: 'required' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const session = await getSession(ctx);
    if (!session) {
      throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
    }

    const user = (await ctx.db.get(session.userId))!;

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

// =============================================================================
// Action Procedures
// =============================================================================

/** Public action - no auth required, supports dev: true in meta */
export const publicAction = c.action.use(devMiddleware);

/** Private action - only callable from other Convex functions */
export const privateAction = c.action.internal();

/** Auth action - ctx.user required, supports dev: true in meta */
export const authAction = c.action
  .meta({ auth: 'required' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    // Actions don't have db access - use runQuery
    const rawUser = await ctx.runQuery(api.user.getSessionUser, {});
    if (!rawUser) {
      throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
    }
    return next({ ctx: { ...ctx, user: rawUser as SessionUser, userId: rawUser.id } });
  });

// =============================================================================
// HTTP Action Procedures
// =============================================================================

/** Public HTTP route - no auth required */
export const publicRoute = c.httpAction;

/** Auth HTTP route - verifies JWT via ctx.auth.getUserIdentity() */
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 as Id<'user'> },
  });
});

/** HTTP router factory - create nested HTTP routers like tRPC */
export const router = c.router;

// =============================================================================
// Exports for Better Auth
// =============================================================================

/** Trigger-wrapped internalMutation for better-auth hooks */
export const internalMutationWithTriggers = customMutation(
  internalMutation,
  customCtx(async (ctx) => ({
    db: triggers.wrapDB(ctx).db,
  }))
);
convex/lib/crpc.ts
import { getHeaders, getSession } from 'better-convex/auth';
import { CRPCError, initCRPC } from 'better-convex/server';
import type { Auth } from 'convex/server';
import {
  customCtx,
  customMutation,
} from 'convex-helpers/server/customFunctions';
import { api } from '../_generated/api';
import type { DataModel, Doc, Id } from '../_generated/dataModel';
import type { ActionCtx, MutationCtx, QueryCtx } from '../_generated/server';
import {
  action,
  httpAction,
  internalAction,
  internalMutation,
  internalQuery,
  mutation,
  query,
} from '../_generated/server';
import { getAuth } from './auth';
import { type CtxWithTable, getCtxWithTable } from './ents';
import { rateLimitGuard } from './rate-limiter';
import { registerTriggers } from './triggers';

// =============================================================================
// Types
// =============================================================================

export type GenericCtx = QueryCtx | MutationCtx | ActionCtx;

type SessionUser = {
  id: Id<'user'>;
  plan?: 'premium' | null;
  isAdmin?: boolean;
  // Add your user fields here
};

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

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

/** Context type for authenticated actions */
export type AuthActionCtx = ActionCtx & {
  user: SessionUser;
  userId: Id<'user'>;
};

// =============================================================================
// Setup
// =============================================================================

const triggers = registerTriggers();

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

const c = initCRPC
  .dataModel<DataModel>()
  .context({
    query: (ctx) => getCtxWithTable(ctx),
    mutation: (ctx) => getCtxWithTable(ctx),
  })
  .meta<Meta>()
  .create({
    query,
    internalQuery,
    // biome-ignore lint/suspicious/noExplicitAny: convex internals
    mutation: (handler: any) =>
      mutation({
        ...handler,
        handler: async (ctx: MutationCtx, args: unknown) => {
          const wrappedCtx = triggers.wrapDB(ctx);
          return handler.handler(wrappedCtx, args);
        },
      }),
    // biome-ignore lint/suspicious/noExplicitAny: convex internals
    internalMutation: (handler: any) =>
      internalMutation({
        ...handler,
        handler: async (ctx: MutationCtx, args: unknown) => {
          const wrappedCtx = triggers.wrapDB(ctx);
          return handler.handler(wrappedCtx, args);
        },
      }),
    action,
    internalAction,
    httpAction,
  });

// =============================================================================
// Middleware
// =============================================================================

/** Dev mode middleware - throws in production if meta.dev: true */
const devMiddleware = c.middleware<object>(({ meta, next, ctx }) => {
  if (meta.dev && process.env.DEPLOY_ENV === 'production') {
    throw new CRPCError({
      code: 'FORBIDDEN',
      message: 'This function is only available in development',
    });
  }
  return next({ ctx });
});

/** Rate limit middleware - applies rate limiting based on meta.rateLimit and user tier */
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 });
});

/** Role middleware - checks admin role from meta after auth middleware */
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 });
});

// =============================================================================
// Query Procedures
// =============================================================================

/** Public query - no auth required, supports dev: true in meta */
export const publicQuery = c.query.use(devMiddleware);

/** Private query - only callable from other Convex functions */
export const privateQuery = c.query.internal();

/** Optional auth query - ctx.user may be null, supports dev: true in meta */
export const optionalAuthQuery = c.query
  .meta({ auth: 'optional' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const session = await getSession(ctx);
    if (!session) {
      return next({ ctx: { ...ctx, user: null, userId: null } });
    }

    const user = await ctx.table('user').getX(session.userId);

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

/** Auth query - ctx.user required, supports role: 'admin' and dev: true in meta */
export const authQuery = c.query
  .meta({ auth: 'required' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const session = await getSession(ctx);
    if (!session) {
      throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
    }

    const user = await ctx.table('user').getX(session.userId);

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

// =============================================================================
// Mutation Procedures
// =============================================================================

/** Public mutation - no auth required, rate limited, supports dev: true in meta */
export const publicMutation = c.mutation
  .use(devMiddleware)
  .use(rateLimitMiddleware);

/** Private mutation - only callable from other Convex functions */
export const privateMutation = c.mutation.internal();

/** Optional auth mutation - ctx.user may be null, rate limited, supports dev: true */
export const optionalAuthMutation = c.mutation
  .meta({ auth: 'optional' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const session = await getSession(ctx);
    if (!session) {
      return next({ ctx: { ...ctx, user: null, userId: null } });
    }

    const user = await ctx.table('user').getX(session.userId);

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

/** Auth mutation - ctx.user required, rate limited, supports role: 'admin' and dev: true */
export const authMutation = c.mutation
  .meta({ auth: 'required' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    const session = await getSession(ctx);
    if (!session) {
      throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
    }

    const user = await ctx.table('user').getX(session.userId);

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

// =============================================================================
// Action Procedures
// =============================================================================

/** Public action - no auth required, supports dev: true in meta */
export const publicAction = c.action.use(devMiddleware);

/** Private action - only callable from other Convex functions */
export const privateAction = c.action.internal();

/** Auth action - ctx.user required, supports dev: true in meta */
export const authAction = c.action
  .meta({ auth: 'required' })
  .use(devMiddleware)
  .use(async ({ ctx, next }) => {
    // Actions don't have db access - use runQuery
    const rawUser = await ctx.runQuery(api.user.getSessionUser, {});
    if (!rawUser) {
      throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
    }
    return next({ ctx: { ...ctx, user: rawUser as SessionUser, userId: rawUser.id } });
  });

// =============================================================================
// HTTP Action Procedures
// =============================================================================

/** Public HTTP route - no auth required */
export const publicRoute = c.httpAction;

/** Auth HTTP route - verifies JWT via ctx.auth.getUserIdentity() */
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 as Id<'user'> },
  });
});

/** HTTP router factory - create nested HTTP routers like tRPC */
export const router = c.router;

// =============================================================================
// Exports for Better Auth
// =============================================================================

/** Trigger-wrapped internalMutation for better-auth hooks */
export const internalMutationWithTriggers = customMutation(
  internalMutation,
  customCtx(async (ctx) => ({
    db: triggers.wrapDB(ctx).db,
  }))
);

types.ts

Type utilities for API inference and document types.

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

import type { api } from '../functions/_generated/api';
import type { Doc, TableNames } from '../functions/_generated/dataModel';
// biome-ignore lint/style/noRestrictedImports: type
import type { appRouter } from '../functions/http';

export type DocWithId<TableName extends TableNames> = WithoutSystemFields<
  Doc<TableName>
> & {
  id: Doc<TableName>['_id'];
};

// API type with HTTP router (http is optional for type inference)
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 { betterAuth, type BetterAuthOptions } from 'better-auth';
import { convex } from '@convex-dev/better-auth/plugins';
import { admin } from 'better-auth/plugins';
import { createClient, createApi, type AuthFunctions } from 'better-convex/auth';
import { internal } from './_generated/api';
import type { MutationCtx, QueryCtx } from './_generated/server';
import type { GenericCtx } from '../lib/crpc';
import type { DataModel } from './_generated/dataModel';
import { internalMutationWithTriggers } from '../lib/crpc';
import schema from './schema';
import authConfig from './auth.config';

const authFunctions: AuthFunctions = internal.auth;

export const authClient = createClient<DataModel, typeof schema>({
  authFunctions,
  schema,
  internalMutation: internalMutationWithTriggers,
  triggers: {
    user: {
      beforeCreate: async (_ctx, data) => {
        const username =
          data.username?.trim() ||
          data.email?.split('@')[0] ||
          `user-${Date.now()}`;
        return { ...data, username };
      },
      onCreate: async (ctx, user) => {
        await ctx.db.insert('profiles', {
          userId: user._id,
          bio: '',
        });
      },
    },
  },
});

export const createAuthOptions = (ctx: GenericCtx) =>
  ({
    baseURL: process.env.SITE_URL!,
    database: authClient.httpAdapter(ctx),
    plugins: [
      convex({
        authConfig,
        jwks: process.env.JWKS,
        jwt: {
          expirationSeconds: 60 * 60, // 1 hour
        },
      }),
      admin(),
    ],
    session: {
      expiresIn: 60 * 60 * 24 * 30,
      updateAge: 60 * 60 * 24 * 15,
    },
    socialProviders: {
      google: {
        clientId: process.env.GOOGLE_CLIENT_ID!,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      },
    },
    trustedOrigins: [process.env.SITE_URL ?? 'http://localhost:3000'],
  }) satisfies BetterAuthOptions;

export const createAuth = (ctx: GenericCtx) => betterAuth(createAuthOptions(ctx));

export const getAuth = <Ctx extends QueryCtx | MutationCtx>(ctx: Ctx) => {
  return betterAuth({
    ...createAuthOptions(ctx),
    database: authClient.adapter(ctx, createAuthOptions),
  });
};

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

export const {
  create,
  deleteMany,
  deleteOne,
  findMany,
  findOne,
  updateMany,
  updateOne,
  getLatestJwks,
  rotateKeys,
} = createApi(schema, createAuth, {
  internalMutation: internalMutationWithTriggers,
  skipValidation: true,
});

// biome-ignore lint/suspicious/noExplicitAny: Required for CLI
export const auth = betterAuth(createAuthOptions({} as any));
convex/functions/auth.ts
import { betterAuth, type BetterAuthOptions } from 'better-auth';
import { convex } from '@convex-dev/better-auth/plugins';
import { admin } from 'better-auth/plugins';
import { createClient, createApi, type AuthFunctions } from 'better-convex/auth';
import { entsTableFactory } from 'convex-ents';
import { entDefinitions } from '../lib/ents';
import { internalMutationWithTriggers } from '../lib/crpc';
import { internal } from './_generated/api';
import type { MutationCtx, QueryCtx } from './_generated/server';
import type { GenericCtx } from '../lib/crpc';
import type { DataModel } from './_generated/dataModel';
import schema from './schema';
import authConfig from './auth.config';

const authFunctions: AuthFunctions = internal.auth;

export const authClient = createClient<DataModel, typeof schema>({
  authFunctions,
  schema,
  internalMutation: internalMutationWithTriggers,
  triggers: {
    user: {
      beforeCreate: async (_ctx, data) => {
        const username =
          data.username?.trim() ||
          data.email?.split('@')[0] ||
          `user-${Date.now()}`;
        return { ...data, username };
      },
      onCreate: async (ctx, user) => {
        const table = entsTableFactory(ctx, entDefinitions);

        await table('profiles').insert({
          userId: user._id,
          bio: '',
        });
      },
    },
  },
});

export const createAuthOptions = (ctx: GenericCtx) =>
  ({
    baseURL: process.env.SITE_URL!,
    database: authClient.httpAdapter(ctx),
    plugins: [
      convex({
        authConfig,
        jwks: process.env.JWKS,
        jwt: {
          expirationSeconds: 60 * 60, // 1 hour
        },
      }),
      admin(),
    ],
    session: {
      expiresIn: 60 * 60 * 24 * 30,
      updateAge: 60 * 60 * 24 * 15,
    },
    socialProviders: {
      google: {
        clientId: process.env.GOOGLE_CLIENT_ID!,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      },
    },
    trustedOrigins: [process.env.SITE_URL ?? 'http://localhost:3000'],
  }) satisfies BetterAuthOptions;

export const createAuth = (ctx: GenericCtx) => betterAuth(createAuthOptions(ctx));

export const getAuth = <Ctx extends QueryCtx | MutationCtx>(ctx: Ctx) => {
  return betterAuth({
    ...createAuthOptions(ctx),
    database: authClient.adapter(ctx, createAuthOptions),
  });
};

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

export const {
  create,
  deleteMany,
  deleteOne,
  findMany,
  findOne,
  updateMany,
  updateOne,
  getLatestJwks,
  rotateKeys,
} = createApi(schema, createAuth, {
  internalMutation: internalMutationWithTriggers,
  skipValidation: true,
});

// biome-ignore lint/suspicious/noExplicitAny: Required for CLI
export const auth = betterAuth(createAuthOptions({} 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 '@convex-dev/better-auth/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 with Hono integration for Better Auth and cRPC API router.

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 { createAuth } 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(createAuth));

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

export default createHttpRouter(app, appRouter);

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 { zid } from 'convex-helpers/server/zod';
import { optionalAuthQuery } from '../lib/crpc';

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

    return {
      id: user.id,
      email: user.email,
      image: user.image,
      name: user.name,
    };
  });

auth-shared.ts

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

convex/shared/auth-shared.ts
import type { Doc, Id } from '../functions/_generated/dataModel';

export type SessionUser = Omit<Doc<'user'>, '_creationTime' | '_id'> & {
  id: Id<'user'>;
  isAdmin: boolean;
  session: Doc<'session'>;
  impersonatedBy?: string;
  plan?: 'premium' | 'team';
};

auth-client.ts

Better Auth client for React with plugins.

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

// Import your Auth type from convex
import type { Auth } from '@convex/auth-shared';

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

export const { useActiveOrganization, useListOrganizations } = authClient;

// Export mutation option hooks
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 { ReactQueryDevtools } from '@tanstack/react-query-devtools';
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}
        <ReactQueryDevtools buttonPosition="bottom-left" initialIsOpen={false} />
      </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.log(`[CRPC] ${error.code}:`, error.functionName);
        }
      },
    }),
    defaultOptions: {
      ...hydrationConfig,
      mutations: {
        onError: (err) => {
          const error = err as Error & { data?: { message?: string } };
          toast.error(error.data?.message || error.message || 'Something went wrong');
        },
      },
      queries: {
        retry: (failureCount, error) => {
          // Don't retry deterministic CRPC errors (auth, validation, HTTP 4xx)
          if (isCRPCError(error)) return false;

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

          // Retry timeouts
          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
const createRSCContext = cache(async () =>
  createContext({ headers: await headers() })
);

/** RSC caller for server-side data fetching */
export const caller = createCaller(createRSCContext);

// CRPC proxy for RSC (no auth config - handled by QueryClient)
export const crpc = createServerCRPCProxy<Api>({ api, meta });

/** Create server-side QueryClient */
function createServerQueryClient() {
  return new QueryClient({
    defaultOptions: {
      ...hydrationConfig,
      ...getServerQueryClientOptions({
        getToken: caller.getToken,
        convexSiteUrl: env.NEXT_PUBLIC_CONVEX_SITE_URL,
      }),
    },
  });
}

/** Get stable QueryClient per request */
export const getQueryClient = cache(createServerQueryClient);

/** Prefetch query for client hydration (fire-and-forget) */
export function prefetch<T extends { queryKey: readonly unknown[] }>(
  queryOptions: T
): void {
  void getQueryClient().prefetchQuery(queryOptions);
}

/** Preload query - returns data + hydrates for client */
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);
}

/** Hydration wrapper for client components */
export function HydrateClient({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient();
  const dehydratedState = dehydrate(queryClient);

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

Convex Ents

Optional - only needed if using Convex Ents for ctx.table access.

ents.ts

Convex Ents helper for ctx.table access.

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 type CtxWithTable<Ctx extends MutationCtx | QueryCtx = QueryCtx> =
  ReturnType<typeof getCtxWithTable<Ctx>>;

export const getCtxWithTable = <Ctx extends MutationCtx | QueryCtx>(ctx: Ctx) => ({
  ...ctx,
  table: entsTableFactory(ctx, entDefinitions),
});

See Convex Ents documentation for setup.


Database Utilities

Optional utilities for triggers and rate limiting.

triggers.ts

Database triggers for automatic side effects.

convex/lib/triggers.ts
import { Triggers } from 'convex-helpers/server/triggers';
import type { DataModel } from '../_generated/dataModel';

export const registerTriggers = () => {
  const triggers = new Triggers<DataModel>();

  // Example: Auto-maintain counts with aggregates
  // triggers.register('posts', aggregatePostCount.trigger());

  // Example: Activity tracking
  // triggers.register('posts', async (ctx, change) => {
  //   if (change.operation === 'insert') {
  //     console.log('Post created:', change.newDoc.title);
  //   }
  // });

  return triggers;
};

See Triggers documentation for patterns.

rate-limiter.ts

Rate limiting configuration with tiered access.

convex/lib/rate-limiter.ts
import { HOUR, MINUTE, RateLimiter, SECOND } from '@convex-dev/rate-limiter';
import { CRPCError } from 'better-convex/server';
import { components } from '../_generated/api';
import type { MutationCtx } from '../_generated/server';

export const rateLimiter = new RateLimiter(components.rateLimiter, {
  // Token bucket: smooth limiting with burst capacity
  'ai:free': { kind: 'token bucket', period: 10 * SECOND, rate: 10 },
  'ai:premium': { kind: 'token bucket', period: 10 * SECOND, rate: 50 },

  // Fixed window: strict limits per period
  'post/create:free': { kind: 'fixed window', period: MINUTE, rate: 5 },
  'post/create:premium': { kind: 'fixed window', period: MINUTE, rate: 20 },

  // Default fallbacks
  'default:free': { kind: 'fixed window', period: MINUTE, rate: 30 },
  'default:premium': { kind: 'fixed window', period: MINUTE, rate: 100 },
  'default:public': { kind: 'fixed window', period: MINUTE, rate: 20 },
});

type SessionUser = { id: string; plan?: 'premium' | null; isAdmin?: boolean };

/** Get user tier based on subscription */
export function getUserTier(
  user: Pick<SessionUser, 'plan' | 'isAdmin'> | null
): 'free' | 'premium' | 'public' {
  if (!user) return 'public';
  if (user.isAdmin) return 'premium';
  if (user.plan) return 'premium';
  return 'free';
}

/** Build rate limit key with tier suffix, falls back to default */
export function getRateLimitKey(
  baseKey: string,
  tier: 'free' | 'premium' | 'public'
) {
  const specificKey = `${baseKey}:${tier}`;

  // Use specific key if defined, otherwise fall back to default
  if (specificKey in rateLimitConfig) {
    return specificKey;
  }

  return `default:${tier}`;
}

/** Check rate limit and throw if exceeded */
export async function rateLimitGuard(
  ctx: 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
import { CRPCError } from 'better-convex/server';
import { z } from 'zod';
import { privateAction, privateMutation } from '../lib/crpc';
import { internal } from './_generated/api';
import type { TableNames } from './_generated/dataModel';
import schema from './schema';

const DELETE_BATCH_SIZE = 64;

// Tables to exclude from reset (e.g., auth tables you want to preserve)
const excludedTables = new Set<TableNames>([
  // 'user',
  // 'session',
]);

/** Dev-only check helper */
const assertDevOnly = () => {
  if (process.env.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();

    // biome-ignore lint/suspicious/noExplicitAny: dynamic table access
    const results = await ctx
      .table(input.tableName as any)
      .paginate({ cursor: input.cursor, numItems: DELETE_BATCH_SIZE });

    for (const row of results.page) {
      try {
        // biome-ignore lint/suspicious/noExplicitAny: dynamic table access
        await ctx
          .table(input.tableName as any)
          .getX(row._id)
          .delete();
      } catch {
        // Document might have been deleted by a trigger
      }
    }

    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 { CRPCError } from 'better-convex/server';
import { privateMutation } from '../lib/crpc';
import { internal } from './_generated/api';

/**
 * Initialize the database on startup.
 * Runs automatically with: convex dev --run init
 */
export default privateMutation.mutation(async ({ ctx }) => {
  if (process.env.DEPLOY_ENV === 'production') {
    throw new CRPCError({
      code: 'FORBIDDEN',
      message: 'This function is only available in development',
    });
  }

  // Check if database needs seeding (e.g., no users exist)
  const existingUser = await ctx.table('user').first();

  if (!existingUser) {
    console.info('  📦 First init - running seed...');
    await ctx.runMutation(internal.seed.seed, {});
  } else {
    console.info('  ✅ Database already initialized');
  }

  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