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 | ORM, 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, orm, 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.
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"strictFunctionTypes": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"jsx": "react-jsx",
"lib": ["dom", "dom.iterable", "esnext"],
"target": "es2022",
"moduleDetection": "force",
"plugins": [{ "name": "next" }],
"allowImportingTsExtensions": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"paths": {
"@/*": ["./src/*"],
"@convex/*": ["./convex/functions/_generated/*", "./convex/shared/*"]
},
"types": ["bun-types"],
"allowJs": true,
"incremental": true
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"src/**/*.ts*"
],
"exclude": ["node_modules"]
}Server
Required files for any better-convex project.
schema.ts
Database schema definition. This example shows Better Auth tables with the admin plugin.
import {
boolean,
convexTable,
defineRelations,
defineSchema,
index,
text,
timestamp,
} from 'better-convex/orm';
// Better Auth core tables (minimal template)
export const userTable = convexTable(
'user',
{
name: text().notNull(),
email: text().notNull(),
emailVerified: boolean().notNull(),
image: text(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull(),
role: text(),
banned: boolean(),
banReason: text(),
banExpires: timestamp(),
},
(t) => [index('email').on(t.email)]
);
export const sessionTable = convexTable(
'session',
{
token: text().notNull(),
userId: text().references(() => userTable.id, { onDelete: 'cascade' }).notNull(),
expiresAt: timestamp().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull(),
ipAddress: text(),
userAgent: text(),
impersonatedBy: text(),
},
(t) => [index('token').on(t.token), index('userId').on(t.userId)]
);
export const accountTable = convexTable(
'account',
{
accountId: text().notNull(),
providerId: text().notNull(),
userId: text().references(() => userTable.id, { onDelete: 'cascade' }).notNull(),
accessToken: text(),
refreshToken: text(),
idToken: text(),
accessTokenExpiresAt: timestamp(),
refreshTokenExpiresAt: timestamp(),
scope: text(),
password: text(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull(),
},
(t) => [index('accountId').on(t.accountId), index('userId').on(t.userId)]
);
export const verificationTable = convexTable(
'verification',
{
identifier: text().notNull(),
value: text().notNull(),
expiresAt: timestamp().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull(),
},
(t) => [index('identifier').on(t.identifier)]
);
export const jwksTable = convexTable('jwks', {
publicKey: text().notNull(),
privateKey: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
});
export const tables = {
user: userTable,
session: sessionTable,
account: accountTable,
verification: verificationTable,
jwks: jwksTable,
};
const schema = defineSchema(tables, { strict: false });
export const relations = defineRelations(tables);
export default schema;orm.ts
Attach the ORM to your context as ctx.orm (once).
import { createOrm, type GenericOrmCtx } from 'better-convex/orm';
import type { MutationCtx, QueryCtx } from '../functions/_generated/server';
import { relations } from '../functions/schema';
const orm = createOrm({ schema: relations });
export type OrmCtx<Ctx extends QueryCtx | MutationCtx = QueryCtx> =
GenericOrmCtx<Ctx, typeof relations>;
export type OrmQueryCtx = OrmCtx<QueryCtx>;
export type OrmMutationCtx = OrmCtx<MutationCtx>;
export function withOrm<Ctx extends QueryCtx | MutationCtx>(ctx: Ctx) {
return { ...ctx, orm: orm.db(ctx) } as OrmCtx<Ctx>;
}Recommended default for ORM + cRPC: declare createdAt: timestamp().notNull().defaultNow() on tables where you want Date values.
Native (number timestamp) mode:
const orm = createOrm({ schema: relations });In native mode (no explicit createdAt column), system createdAt remains number.
crpc.ts
The cRPC builder with auth middleware and procedure variants.
import { getHeaders } from 'better-convex/auth';
import { CRPCError, initCRPC } from 'better-convex/server';
import type { Auth } from 'convex/server';
import { api } from '../functions/_generated/api';
import type { DataModel } from '../functions/_generated/dataModel';
import type { ActionCtx, MutationCtx, QueryCtx } from '../functions/_generated/server';
import { getAuth } from '../functions/auth';
import type { SessionUser } from '../shared/auth-shared';
import { getSessionUser } from './auth/auth-helpers';
import { getEnv } from './get-env';
import { type OrmCtx, withOrm } from './orm';
import { rateLimitGuard } from './rate-limiter';
/** Context with optional auth - user/userId may be null */
export type MaybeAuthCtx<Ctx extends MutationCtx | QueryCtx = QueryCtx> =
OrmCtx<Ctx> & {
auth: Auth & Partial<ReturnType<typeof getAuth> & { headers: Headers }>;
user: SessionUser | null;
userId: string | null;
};
/** Context with required auth - user/userId guaranteed */
export type AuthCtx<Ctx extends MutationCtx | QueryCtx = QueryCtx> =
OrmCtx<Ctx> & {
auth: Auth & ReturnType<typeof getAuth> & { headers: Headers };
user: SessionUser;
userId: string;
};
/** Context type for authenticated actions */
export type AuthActionCtx = ActionCtx & {
user: SessionUser;
userId: string;
};
type Meta = {
auth?: 'optional' | 'required';
role?: 'admin';
rateLimit?: string;
dev?: boolean;
};
const c = initCRPC
.dataModel<DataModel>()
.context({
query: (ctx) => withOrm(ctx),
mutation: (ctx) => withOrm(ctx),
})
.meta<Meta>()
.create();
const devMiddleware = c.middleware<object>(({ meta, next, ctx }) => {
if (meta.dev && getEnv().DEPLOY_ENV === 'production') {
throw new CRPCError({
code: 'FORBIDDEN',
message: 'This function is only available in development',
});
}
return next({ ctx });
});
const rateLimitMiddleware = c.middleware<
MutationCtx & { user?: Pick<SessionUser, 'id' | 'plan'> | null }
>(async ({ ctx, meta, next }) => {
await rateLimitGuard({
...ctx,
rateLimitKey: meta.rateLimit ?? 'default',
user: ctx.user ?? null,
});
return next({ ctx });
});
const roleMiddleware = c.middleware<object>(({ ctx, meta, next }) => {
const user = (ctx as { user?: { isAdmin?: boolean } }).user;
if (meta.role === 'admin' && !user?.isAdmin) {
throw new CRPCError({ code: 'FORBIDDEN', message: 'Admin access required' });
}
return next({ ctx });
});
function requireAuth<T>(user: T | null): T {
if (!user) {
throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
}
return user;
}
export const publicQuery = c.query.use(devMiddleware);
export const privateQuery = c.query.internal();
export const optionalAuthQuery = c.query
.meta({ auth: 'optional' })
.use(devMiddleware)
.use(async ({ ctx, next }) => {
const user = await getSessionUser(ctx);
return next({
ctx: {
...ctx,
auth: user
? {
...ctx.auth,
...getAuth(ctx),
headers: await getHeaders(ctx, user.session),
}
: ctx.auth,
user,
userId: user?.id ?? null,
},
});
});
export const authQuery = c.query
.meta({ auth: 'required' })
.use(devMiddleware)
.use(async ({ ctx, next }) => {
const user = requireAuth(await getSessionUser(ctx));
return next({
ctx: {
...ctx,
auth: {
...ctx.auth,
...getAuth(ctx),
headers: await getHeaders(ctx, user.session),
},
user,
userId: user.id,
},
});
})
.use(roleMiddleware);
export const publicMutation = c.mutation.use(devMiddleware).use(rateLimitMiddleware);
export const privateMutation = c.mutation.internal();
export const optionalAuthMutation = c.mutation
.meta({ auth: 'optional' })
.use(devMiddleware)
.use(async ({ ctx, next }) => {
const user = await getSessionUser(ctx);
return next({
ctx: {
...ctx,
auth: user
? {
...ctx.auth,
...getAuth(ctx),
headers: await getHeaders(ctx, user.session),
}
: ctx.auth,
user,
userId: user?.id ?? null,
},
});
})
.use(rateLimitMiddleware);
export const authMutation = c.mutation
.meta({ auth: 'required' })
.use(devMiddleware)
.use(async ({ ctx, next }) => {
const user = requireAuth(await getSessionUser(ctx));
return next({
ctx: {
...ctx,
auth: {
...ctx.auth,
...getAuth(ctx),
headers: await getHeaders(ctx, user.session),
},
user,
userId: user.id,
},
});
})
.use(roleMiddleware)
.use(rateLimitMiddleware);
export const publicAction = c.action.use(devMiddleware);
export const privateAction = c.action.internal();
export const authAction = c.action
.meta({ auth: 'required' })
.use(devMiddleware)
.use(async ({ ctx, next }) => {
const user = requireAuth(await ctx.runQuery(api.user.getSessionUser));
return next({ ctx: { ...ctx, user, userId: user.id } });
});
export const publicRoute = c.httpAction;
export const authRoute = c.httpAction.use(async ({ ctx, next }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
}
return next({
ctx: {
...ctx,
userId: identity.subject,
user: {
id: identity.subject,
email: identity.email,
name: identity.name,
},
},
});
});
export const optionalAuthRoute = c.httpAction.use(async ({ ctx, next }) => {
const identity = await ctx.auth.getUserIdentity();
return next({
ctx: {
...ctx,
userId: identity ? identity.subject : null,
user: identity
? {
id: identity.subject,
email: identity.email,
name: identity.name,
}
: null,
},
});
});
export const router = c.router;types.ts
Type utilities for API inference and ORM model types.
import type { InferInsertModel, InferSelectModel } from 'better-convex/orm';
import type {
inferApiInputs,
inferApiOutputs,
WithHttpRouter,
} from 'better-convex/server';
import type { api } from '../functions/_generated/api';
// biome-ignore lint/style/noRestrictedImports: type
import type { appRouter } from '../functions/http';
// biome-ignore lint/style/noRestrictedImports: type
import type { tables } from '../functions/schema';
export type TableName = keyof typeof tables;
export type Select<T extends TableName> = InferSelectModel<(typeof tables)[T]>;
export type Insert<T extends TableName> = InferInsertModel<(typeof tables)[T]>;
export type Api = WithHttpRouter<typeof api, typeof appRouter>;
export type ApiInputs = inferApiInputs<Api>;
export type ApiOutputs = inferApiOutputs<Api>;meta.ts
Generated by the CLI. Contains procedure metadata for auth-aware query handling.
bunx better-convex devBetter Auth
Optional - only needed if using Better Auth for authentication.
auth.ts
Better Auth client setup with Convex adapter.
import { type BetterAuthOptions, betterAuth } from 'better-auth';
import { admin } from 'better-auth/plugins';
import {
type AuthFunctions,
convex,
createApi,
createClient,
} from 'better-convex/auth';
import { type OrmMutationCtx, withOrm } from '../lib/orm';
import { internal } from './_generated/api';
import type { DataModel } from './_generated/dataModel';
import type { ActionCtx, MutationCtx, QueryCtx } from './_generated/server';
import authConfig from './auth.config';
import schema from './schema';
type GenericCtx = QueryCtx | MutationCtx | ActionCtx;
const authFunctions: AuthFunctions = internal.auth;
export const authClient = createClient<
DataModel,
typeof schema,
OrmMutationCtx
>({
authFunctions,
schema,
context: withOrm,
});
const getAuthOptions = (ctx: GenericCtx) =>
({
baseURL: process.env.SITE_URL!,
database: authClient.adapter(ctx, getAuthOptions),
plugins: [
admin(),
convex({
authConfig,
jwks: process.env.JWKS,
}),
],
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24 * 15,
},
trustedOrigins: [process.env.SITE_URL ?? 'http://localhost:3000'],
}) satisfies BetterAuthOptions;
export const getAuth = (ctx: GenericCtx) => betterAuth(getAuthOptions(ctx));
export const {
beforeCreate,
beforeDelete,
beforeUpdate,
onCreate,
onDelete,
onUpdate,
} = authClient.triggersApi();
export const {
create,
deleteMany,
deleteOne,
findMany,
findOne,
updateMany,
updateOne,
getLatestJwks,
rotateKeys,
} = createApi(schema, getAuth, {
context: withOrm,
skipValidation: true,
});
// biome-ignore lint/suspicious/noExplicitAny: Required for CLI
export const auth = betterAuth(getAuthOptions({} as any));See Auth Server documentation for detailed configuration.
auth.config.ts
Auth configuration provider for Convex.
import { getAuthConfigProvider } from 'better-convex/auth-config';
import type { AuthConfig } from 'convex/server';
export default {
providers: [getAuthConfigProvider({ jwks: process.env.JWKS })],
} satisfies AuthConfig;http-polyfills.ts
Convex's runtime environment doesn't include MessageChannel, which Better Auth's HTTP handling requires.
// polyfill MessageChannel without using node:events
if (typeof MessageChannel === 'undefined') {
class MockMessagePort {
onmessage: ((ev: MessageEvent) => void) | undefined;
onmessageerror: ((ev: MessageEvent) => void) | undefined;
addEventListener() {}
close() {}
dispatchEvent(_event: Event): boolean {
return false;
}
postMessage(_message: unknown, _transfer: Transferable[] = []) {}
removeEventListener() {}
start() {}
}
class MockMessageChannel {
port1: MockMessagePort;
port2: MockMessagePort;
constructor() {
this.port1 = new MockMessagePort();
this.port2 = new MockMessagePort();
}
}
globalThis.MessageChannel =
MockMessageChannel as unknown as typeof MessageChannel;
}Import this at the top of your HTTP file (before other imports).
http.ts
HTTP routes for Better Auth.
import '../lib/http-polyfills';
import { authMiddleware } from 'better-convex/auth';
import { createHttpRouter } from 'better-convex/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { router } from '../lib/crpc';
// Import your routers from convex/routers/
import { getAuth } from './auth';
const app = new Hono();
// CORS for API routes (auth + cRPC)
app.use(
'/api/*',
cors({
origin: process.env.SITE_URL!,
allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
exposeHeaders: ['Set-Better-Auth-Cookie'],
credentials: true,
})
);
// Better Auth middleware
app.use(authMiddleware(getAuth));
// HTTP API router (tRPC-style)
export const appRouter = router({
// Add your routers here
});
export default createHttpRouter(app, appRouter);import '../lib/http-polyfills';
import { registerRoutes } from 'better-convex/auth';
import { httpRouter } from 'convex/server';
import { getAuth } from './auth';
const http = httpRouter();
registerRoutes(http, getAuth, {
cors: {
allowedOrigins: [process.env.SITE_URL!],
},
});
export default http;See HTTP Router documentation for route examples.
user.ts
User queries including getSessionUser for auth state caching.
import { z } from 'zod';
import { optionalAuthQuery, publicQuery } from '../lib/crpc';
/** Get session user - used by AuthSync and authAction */
export const getSessionUser = optionalAuthQuery
.output(
z.union([
z.object({
id: z.string(),
image: z.string().nullish(),
isAdmin: z.boolean(),
name: z.string().optional(),
plan: z.string().optional(),
}),
z.null(),
])
)
.query(async ({ ctx }) => {
const { user } = ctx;
if (!user) {
return null;
}
return {
id: user.id,
image: user.image,
isAdmin: user.isAdmin ?? false,
name: user.name,
plan: user.plan,
};
});
/** Check if user is authenticated */
export const getIsAuthenticated = publicQuery
.output(z.boolean())
.query(async ({ ctx }) => !!(await ctx.auth.getUserIdentity()));auth-shared.ts
Session user type for auth context. Customize based on your user table fields.
import type { Doc } from '../functions/_generated/dataModel';
// biome-ignore lint/style/noRestrictedImports: types
import type { getAuth } from '../functions/auth';
import type { Select } from './types';
export type Auth = ReturnType<typeof getAuth>;
export type SessionUser = Select<'user'> & {
isAdmin: boolean;
// Native Better Auth session document (used for auth header/session plumbing).
session: Doc<'session'>;
impersonatedBy?: string | null;
plan?: 'premium' | 'team';
};auth-client.ts
Better Auth client for React with plugins.
import type { Auth } from '@convex/auth-shared';
import { adminClient, inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import { convexClient } from 'better-convex/auth-client';
import { createAuthMutations } from 'better-convex/react';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_SITE_URL!,
sessionOptions: {
// Disable session polling on tab focus (saves ~500ms HTTP call per focus)
refetchOnWindowFocus: false,
},
plugins: [inferAdditionalFields<Auth>(), adminClient(), convexClient()],
});
export const { useActiveOrganization, useListOrganizations } = authClient;
export const {
useSignOutMutationOptions,
useSignInSocialMutationOptions,
useSignInMutationOptions,
useSignUpMutationOptions,
} = createAuthMutations(authClient);React Client
Optional - needed for React apps using TanStack Query integration.
convex-provider.tsx
Convex provider with TanStack Query integration.
'use client';
import { QueryClientProvider as TanstackQueryClientProvider } from '@tanstack/react-query';
import { ConvexAuthProvider } from 'better-convex/auth-client';
import {
ConvexReactClient,
getConvexQueryClientSingleton,
getQueryClientSingleton,
useAuthStore,
} from 'better-convex/react';
import { useRouter } from 'next/navigation';
import type { ReactNode } from 'react';
import { toast } from 'sonner';
import { authClient } from '@/lib/convex/auth-client';
import { CRPCProvider } from '@/lib/convex/crpc';
import { createQueryClient } from '@/lib/convex/query-client';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function BetterConvexProvider({
children,
token,
}: {
children: ReactNode;
token?: string;
}) {
const router = useRouter();
return (
<ConvexAuthProvider
authClient={authClient}
client={convex}
initialToken={token}
onMutationUnauthorized={() => {
router.push('/login');
}}
onQueryUnauthorized={({ queryName }) => {
if (process.env.NODE_ENV === 'development') {
toast.error(`${queryName} requires authentication`);
} else {
router.push('/login');
}
}}
>
<QueryClientProvider>{children}</QueryClientProvider>
</ConvexAuthProvider>
);
}
function QueryClientProvider({ children }: { children: ReactNode }) {
const authStore = useAuthStore();
const queryClient = getQueryClientSingleton(createQueryClient);
const convexQueryClient = getConvexQueryClientSingleton({
authStore,
convex,
queryClient,
});
return (
<TanstackQueryClientProvider client={queryClient}>
<CRPCProvider convexClient={convex} convexQueryClient={convexQueryClient}>
{children}
</CRPCProvider>
</TanstackQueryClientProvider>
);
}query-client.ts
QueryClient configuration with hydration and error handling.
import {
type DefaultOptions,
defaultShouldDehydrateQuery,
QueryCache,
QueryClient,
} from '@tanstack/react-query';
import { isCRPCClientError, isCRPCError } from 'better-convex/crpc';
import { toast } from 'sonner';
import SuperJSON from 'superjson';
/** Shared hydration config for SSR data transfer (used by client + server) */
export const hydrationConfig: Pick<DefaultOptions, 'dehydrate' | 'hydrate'> = {
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
shouldRedactErrors: () => false,
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
};
/** Create QueryClient for client-side with error handling */
export function createQueryClient() {
return new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (isCRPCClientError(error)) {
console.warn(`[CRPC] ${error.code}:`, error.functionName);
}
},
}),
defaultOptions: {
...hydrationConfig,
mutations: {
onError: (err, _variables, _context, mutation) => {
const error = err as Error & { data?: { message?: string } };
const meta = mutation.meta as
| { errorMessage?: string; skipErrorToast?: boolean }
| undefined;
if (meta?.skipErrorToast) return;
toast.error(
error.data?.message ||
meta?.errorMessage ||
error.message ||
'Something went wrong'
);
},
},
queries: {
retry: (failureCount, error) => {
if (isCRPCError(error)) return false;
const message =
error instanceof Error ? error.message : String(error);
if (message.includes('timed out') && failureCount < 3) {
console.warn(
`[QueryClient] Retrying timed out query (attempt ${failureCount + 1}/3)`
);
return true;
}
return failureCount < 3;
},
retryDelay: (attemptIndex) =>
Math.min(2000 * 2 ** attemptIndex, 30_000),
},
},
});
}crpc.tsx
CRPC context provider for React with HTTP router support.
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 - wraps createContext with cache() and next/headers
const createRSCContext = cache(async () =>
createContext({ headers: await headers() })
);
export const caller = createCaller(createRSCContext);
export const crpc = createServerCRPCProxy<Api>({ api, meta });
function createServerQueryClient() {
return new QueryClient({
defaultOptions: {
...hydrationConfig,
...getServerQueryClientOptions({
getToken: caller.getToken,
convexSiteUrl: env.NEXT_PUBLIC_CONVEX_SITE_URL,
}),
},
});
}
export const getQueryClient = cache(createServerQueryClient);
export function prefetch<T extends { queryKey: readonly unknown[] }>(
queryOptions: T
): void {
void getQueryClient().prefetchQuery(queryOptions);
}
export function preloadQuery<
TQueryFnData = unknown,
TError = Error,
TData = TQueryFnData,
TQueryKey extends readonly unknown[] = readonly unknown[],
>(
options: FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>
): Promise<TData> {
return getQueryClient().fetchQuery(options);
}
export function HydrateClient({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const dehydratedState = dehydrate(queryClient);
return (
<HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
);
}Database Utilities
Optional utilities for triggers and rate limiting.
schema triggers
Define side effects directly in convex/functions/schema.ts.
import { convexTable, onChange } from 'better-convex/orm';
import { aggregatePostCount } from './aggregates';
export const postsTable = convexTable('posts', {/* columns */}, () => [
aggregatePostCount.trigger(),
onChange(async (_ctx, change) => {
console.log('Post changed:', change.operation, change.id);
}),
]);See Triggers documentation for patterns.
rate-limiter.ts
Rate limiting configuration with tiered access.
import { MINUTE, RateLimiter } from '@convex-dev/rate-limiter';
import { CRPCError } from 'better-convex/server';
import { components } from '../functions/_generated/api';
import type { ActionCtx, MutationCtx } from '../functions/_generated/server';
import type { SessionUser } from '../shared/auth-shared';
const rateLimitConfig = {
'default:free': { kind: 'fixed window', period: MINUTE, rate: 60 },
'default:premium': { kind: 'fixed window', period: MINUTE, rate: 200 },
'default:public': { kind: 'fixed window', period: MINUTE, rate: 30 },
// Example endpoint-specific overrides
'todo/create:free': { kind: 'fixed window', period: MINUTE, rate: 20 },
'todo/create:premium': { kind: 'fixed window', period: MINUTE, rate: 60 },
} as const;
export const rateLimiter = new RateLimiter(
components.rateLimiter,
rateLimitConfig
);
export function getRateLimitKey(
baseKey: string,
tier: 'free' | 'premium' | 'public'
): string {
const specificKey = `${baseKey}:${tier}`;
if (specificKey in rateLimitConfig) {
return specificKey;
}
return `default:${tier}`;
}
export function getUserTier(
user: { isAdmin?: boolean; plan?: SessionUser['plan'] } | null
): 'free' | 'premium' | 'public' {
if (!user) return 'public';
if (user.isAdmin) return 'premium';
if (user.plan) return 'premium';
return 'free';
}
export async function rateLimitGuard(
ctx: (ActionCtx | MutationCtx) & {
rateLimitKey: string;
user: Pick<SessionUser, 'id' | 'plan'> | null;
}
) {
const tier = getUserTier(ctx.user);
const limitKey = getRateLimitKey(ctx.rateLimitKey, tier);
const identifier = ctx.user?.id ?? 'anonymous';
const status = await rateLimiter.limit(ctx, limitKey, {
key: identifier,
});
if (!status.ok) {
throw new CRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded. Please try again later.',
});
}
}See Rate Limiting documentation for setup.
Dev Scripts
Optional scripts for development workflow.
reset.ts
Development-only database reset.
/** biome-ignore-all lint/suspicious/noExplicitAny: dev */
import { eq } from 'better-convex/orm';
import { CRPCError } from 'better-convex/server';
import { z } from 'zod';
import { privateAction, privateMutation } from '../lib/crpc';
import { getEnv } from '../lib/get-env';
import { internal } from './_generated/api';
import type { TableNames } from './_generated/dataModel';
import schema, { tables } from './schema';
const DELETE_BATCH_SIZE = 64;
const excludedTables = new Set<TableNames>();
const assertDevOnly = () => {
if (getEnv().DEPLOY_ENV === 'production') {
throw new CRPCError({
code: 'FORBIDDEN',
message: 'This function is only available in development',
});
}
};
export const reset = privateAction.output(z.null()).action(async ({ ctx }) => {
assertDevOnly();
for (const tableName of Object.keys(schema.tables)) {
if (excludedTables.has(tableName as TableNames)) {
continue;
}
await ctx.scheduler.runAfter(0, internal.reset.deletePage, {
cursor: null,
tableName,
});
}
return null;
});
export const deletePage = privateMutation
.input(
z.object({
cursor: z.union([z.string(), z.null()]),
tableName: z.string(),
})
)
.output(z.null())
.mutation(async ({ ctx, input }) => {
assertDevOnly();
const table = (tables as Record<string, any>)[input.tableName];
if (!table) {
throw new CRPCError({
code: 'BAD_REQUEST',
message: `Unknown table: ${input.tableName}`,
});
}
const query = (ctx.orm.query as Record<string, any>)[input.tableName];
if (!query || typeof query.findMany !== 'function') {
throw new CRPCError({
code: 'BAD_REQUEST',
message: `Unknown query table: ${input.tableName}`,
});
}
const results = await query.findMany({
cursor: input.cursor,
limit: DELETE_BATCH_SIZE,
});
for (const row of results.page) {
try {
await ctx.orm.delete(table).where(eq(table.id, (row as any).id));
} catch {
// Document might have been deleted by a trigger or concurrent process
}
}
if (!results.isDone) {
await ctx.scheduler.runAfter(0, internal.reset.deletePage, {
cursor: results.continueCursor,
tableName: input.tableName,
});
}
return null;
});init.ts
Development initialization that runs on convex dev --run init.
import { z } from 'zod';
import { createUser } from '../lib/auth/auth-helpers';
import { privateMutation } from '../lib/crpc';
import { getEnv } from '../lib/get-env';
import { internal } from './_generated/api';
/**
* Initialize the database on startup. This runs with: convex dev --run init
*/
export default privateMutation
.meta({ dev: true })
.output(z.null())
.mutation(async ({ ctx }) => {
const env = getEnv();
const adminEmails = env.ADMIN;
if (!adminEmails || adminEmails.length === 0) {
return null;
}
let isFirstInit = true;
for (const adminEmail of adminEmails) {
const existingUser = await ctx.orm.query.user.findFirst({
where: { email: adminEmail },
});
if (existingUser) {
isFirstInit = false;
continue;
}
await createUser(ctx, {
email: adminEmail,
name: 'Admin',
role: 'admin',
});
}
if (isFirstInit && getEnv().DEPLOY_ENV === 'development') {
await ctx.runMutation(internal.seed.seed, {});
}
return null;
});package.json scripts
Common scripts for development workflow:
{
"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."
}]
}
}
}
}
}
}
]
}