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
| Section | What's Included |
|---|---|
| Project Structure | Monolithic folder layout, import rules |
| Environment Variables | Local and cloud configuration |
| Server | Schema, cRPC builder, types, metadata |
| Better Auth | Auth setup, triggers, HTTP routes |
| React Client | Providers, QueryClient, cRPC context |
| Next.js RSC | Server utilities, prefetching, hydration |
| Database Utilities | Ents, triggers, rate limiting |
| Dev Scripts | Reset, 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 fromconvex/shared/via@convex/*aliassrc/cannot import fromconvex/functions/orconvex/lib/src/should not import fromconvex/*packages (usebetter-convex/*wrappers)convex/cannot import fromsrc/convex/shared/cannot import fromconvex/lib/(keeps it client-safe)
Environment Variables
Required environment variables for better-convex:
# 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:3000Port 3210 is for WebSocket connections, port 3211 is for HTTP actions (auth routes).
# 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:3000TypeScript Config
tsconfig.json
Root TypeScript configuration with path aliases for @convex/* imports.
{
"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.
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
});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.
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,
}))
);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.
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 devBetter Auth
Optional - only needed if using Better Auth for authentication.
auth.ts
Better Auth client setup with Convex adapter.
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));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.
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.
// 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.
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.
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.
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.
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.
'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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
{
"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"
}
}| Script | Description |
|---|---|
convex:dev | Initialize DB then run Convex dev with meta generation |
reset | Clear all tables then reinitialize |
seed | Run seed function manually |
sync:jwks | Sync JWKS from Convex to env |
sync:rotate | Rotate keys and update JWKS |
Biome
Import boundary enforcement with Ultracite:
{
"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."
}]
}
}
}
}
}
}
]
}