Metadata
Add typed metadata to procedures for middleware configuration.
In this guide, we'll explore procedure metadata - a way to configure behavior per-procedure. You'll learn to define typed metadata, access it in middleware, and use common patterns like auth levels, roles, and rate limiting.
Overview
Procedure metadata lets you attach typed configuration to individual procedures. Middleware reads this metadata to customize behavior - checking roles, applying rate limits, or enforcing auth requirements.
| Feature | Purpose |
|---|---|
| Type-safe metadata | Define Meta type for autocomplete and validation |
| Per-procedure config | Set different metadata on each procedure |
| Middleware access | Read meta in middleware to customize logic |
| Default values | Set defaultMeta for all procedures |
| Shallow merging | Chain .meta() calls to build up configuration |
Let's explore each one.
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
Let's start by defining your 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 }) => { ... });Migrate from Convex
If you're coming from vanilla Convex, here's what changes.
What stays the same
- Custom behavior per procedure
What's new
No built-in metadata system. You'd need to implement custom patterns.
const c = initCRPC
.dataModel<DataModel>()
.meta<{ role?: 'admin' }>()
.create();
export const adminQuery = authQuery
.meta({ role: 'admin' })
.use(roleMiddleware);Key differences:
- Type-safe metadata with
Metatype - Middleware accesses
metaparameter - Chain
.meta()calls with shallow merge defaultMetafor default values