Metadata
Add typed metadata to procedures for middleware configuration.
Client Metadata (Codegen)
The CLI generates convex/shared/api.ts automatically. Each public procedure leaf includes metadata plus a functionRef:
// Auto-generated by `kitcn dev`
export const api = {
user: {
list: {
type: 'query',
auth: 'optional',
functionRef: /* Convex FunctionReference */,
},
create: {
type: 'mutation',
auth: 'required',
ratelimit: 'user/create',
functionRef: /* Convex FunctionReference */,
},
},
admin: {
list: {
type: 'query',
auth: 'required',
role: 'admin',
functionRef: /* Convex FunctionReference */,
},
},
} as const;Important: Never create convex/shared/api.ts manually. It's generated and should not contain secrets.
Define Meta Type
Chain .meta<{ ... }>() during initialization:
import { initCRPC } from 'kitcn/server';
const c = initCRPC
.dataModel<DataModel>()
.meta<{
auth?: 'optional' | 'required';
role?: 'admin';
ratelimit?: string;
}>()
.create();Now TypeScript knows what metadata properties are valid.
Set Procedure Metadata
Use .meta() to set metadata on procedures:
export const adminOnly = authQuery
.meta({ role: 'admin' })
.query(async ({ ctx }) => {
return ctx.orm.query.user.findMany({ limit: 100 });
});
export const createSession = authMutation
.meta({ ratelimit: 'session/create' })
.input(z.object({ token: z.string() }))
.mutation(async ({ ctx, input }) => {
const [row] = await ctx.orm
.insert(session)
.values({ token: input.token, userId: ctx.userId })
.returning({ id: session.id });
return row.id;
});Access in Middleware
Access meta in middleware to customize behavior:
const roleMiddleware = c.middleware(({ ctx, meta, next }) => {
if (meta.role === 'admin' && !ctx.user?.isAdmin) {
throw new CRPCError({ code: 'FORBIDDEN' });
}
return next({ ctx });
});
const ratelimit = RatelimitPlugin.configure({
buckets: ratelimitBuckets,
getBucket: ({ meta }: { meta: { ratelimit?: string } }) =>
meta.ratelimit ?? 'default',
// ...
});The role middleware reads meta.role, and the ratelimit plugin reads meta.ratelimit through getBucket(...).
Default Meta
You can set default metadata values in create(). All procedures will start with these values:
const c = initCRPC
.dataModel<DataModel>()
.meta<{
auth?: 'optional' | 'required';
role?: 'admin';
ratelimit?: string;
}>()
.create({
defaultMeta: { auth: 'optional' },
});
// All procedures start with { auth: 'optional' }Chaining (Shallow Merge)
Chaining .meta() calls shallow merges values. Each call adds to or overrides the previous metadata:
export const publicQuery = c.query;
// Meta: { auth: 'optional' } (from defaultMeta)
export const authQuery = c.query
.meta({ auth: 'required' });
// Meta: { auth: 'required' }
export const adminQuery = authQuery
.meta({ role: 'admin' });
// Meta: { auth: 'required', role: 'admin' }
export const ratelimitedAdmin = adminQuery
.meta({ ratelimit: 'admin/heavy' });
// Meta: { auth: 'required', role: 'admin', ratelimit: 'admin/heavy' }This makes it easy to build procedure variants with progressively more configuration.
Common Patterns
Here are common metadata patterns.
Auth Level
The auth metadata controls both server and client behavior:
Server-side: Middleware checks authentication Client-side: Query waits for auth loading and skips appropriately
auth value | Server | Client (auth loading) | Client (logged out) |
|---|---|---|---|
| (none) | No check | Runs immediately | Runs |
'optional' | User optional | Waits | Runs |
'required' | User required | Waits | Skips |
Use auth metadata to distinguish between optional and required authentication:
const c = initCRPC
.dataModel<DataModel>()
.meta<{ auth?: 'optional' | 'required' }>()
.create();
export const optionalAuthQuery = c.query
.meta({ auth: 'optional' })
.use(async ({ ctx, next }) => {
const user = await getSessionUser(ctx);
return next({ ctx: { ...ctx, user } });
});
export const authQuery = c.query
.meta({ auth: 'required' })
.use(async ({ ctx, next }) => {
const user = await getSessionUser(ctx);
if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { ...ctx, user } });
});Role-Based Access
Using the roleMiddleware from Access in Middleware, build an admin procedure variant:
export const adminQuery = authQuery
.meta({ role: 'admin' })
.use(roleMiddleware);
// Usage
export const list = adminQuery
.query(async ({ ctx }) => {
return ctx.orm.query.user.findMany({ limit: 100 });
});Rate Limiting
Apply different rate limits based on metadata:
const c = initCRPC
.dataModel<DataModel>()
.meta<{ ratelimit?: string }>()
.create();
export const createSession = authMutation
.meta({ ratelimit: 'session/create' })
.use(ratelimit.middleware())
.mutation(async ({ ctx, input }) => { ... });Dev Mode
Restrict certain procedures to development only:
const c = initCRPC
.dataModel<DataModel>()
.meta<{ dev?: boolean }>()
.create();
const devMiddleware = c.middleware(({ meta, next, ctx }) => {
if (meta.dev && process.env.NODE_ENV === 'production') {
throw new CRPCError({
code: 'FORBIDDEN',
message: 'This function is only available in development',
});
}
return next({ ctx });
});
export const debugQuery = publicQuery
.meta({ dev: true })
.query(async ({ ctx }) => { ... });