Metadata
Add typed metadata to procedures for middleware configuration.
In this guide, we'll explore procedure metadata - a powerful 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/meta.ts containing metadata for all procedures. The client uses this to determine function types and auth requirements:
// Auto-generated by `better-convex dev`
export const meta = {
user: {
list: { type: 'query', auth: 'optional' },
create: { type: 'mutation', auth: 'required', rateLimit: 'user/create' },
},
admin: {
list: { type: 'query', auth: 'required', role: 'admin' },
},
} as const;Important: No secrets in metadata. Since meta is exported to the client bundle, never store sensitive values like API keys, internal URLs, or configuration secrets in procedure metadata.
Define Meta Type
Let's start by defining your meta type. Chain .meta<Meta>() during initialization:
import { initCRPC } from 'better-convex/server';
type Meta = {
auth?: 'optional' | 'required';
role?: 'admin';
rateLimit?: string;
};
const c = initCRPC
.dataModel<DataModel>()
.meta<Meta>()
.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.db.query('user').collect();
});
export const createSession = authMutation
.meta({ rateLimit: 'session/create' })
.input(z.object({ token: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.insert('session', { token: input.token, userId: ctx.userId });
});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 rateLimitMiddleware = c.middleware(async ({ ctx, meta, next }) => {
await rateLimitGuard({
key: meta.rateLimit ?? 'default',
userId: ctx.userId,
});
return next({ ctx });
});The middleware reads meta.role and meta.rateLimit to decide what checks to apply.
Default Meta
You can set default metadata values in create(). All procedures will start with these values:
const c = initCRPC
.dataModel<DataModel>()
.meta<Meta>()
.create({
defaultMeta: { auth: 'optional' },
query,
mutation,
});
// 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 battle-tested metadata patterns you can copy into your project.
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:
type Meta = {
auth?: 'optional' | 'required';
};
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
Add role checks that run after authentication:
type Meta = {
role?: 'admin';
};
const roleMiddleware = c.middleware(({ ctx, meta, next }) => {
if (meta.role === 'admin' && !ctx.user?.isAdmin) {
throw new CRPCError({
code: 'FORBIDDEN',
message: 'Admin access required',
});
}
return next({ ctx });
});
export const adminQuery = authQuery
.meta({ role: 'admin' })
.use(roleMiddleware);
// Usage
export const list = adminQuery
.query(async ({ ctx }) => {
return ctx.db.query('user').collect();
});Rate Limiting
Apply different rate limits based on metadata:
type Meta = {
rateLimit?: string;
};
const rateLimitMiddleware = c.middleware(async ({ ctx, meta, next }) => {
if (meta.rateLimit) {
await rateLimitGuard({
key: meta.rateLimit,
userId: ctx.userId,
});
}
return next({ ctx });
});
export const createSession = authMutation
.meta({ rateLimit: 'session/create' })
.use(rateLimitMiddleware)
.mutation(async ({ ctx, input }) => { ... });Dev Mode
Restrict certain procedures to development only:
type Meta = {
dev?: boolean;
};
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
Before (vanilla Convex):
No built-in metadata system - you'd need to implement custom patterns.
After (cRPC):
type Meta = { role?: 'admin' };
const c = initCRPC
.dataModel<DataModel>()
.meta<Meta>()
.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