BETTER-CONVEX

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.

FeaturePurpose
Type-safe metadataDefine Meta type for autocomplete and validation
Per-procedure configSet different metadata on each procedure
Middleware accessRead meta in middleware to customize logic
Default valuesSet defaultMeta for all procedures
Shallow mergingChain .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:

convex/shared/api.ts
// 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:

convex/lib/crpc.ts
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:

convex/functions/admin.ts
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:

convex/lib/crpc.ts
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:

convex/lib/crpc.ts
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 valueServerClient (auth loading)Client (logged out)
(none)No checkRuns immediatelyRuns
'optional'User optionalWaitsRuns
'required'User requiredWaitsSkips

Use auth metadata to distinguish between optional and required authentication:

convex/lib/crpc.ts
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:

convex/lib/crpc.ts
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:

convex/lib/crpc.ts
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:

convex/lib/crpc.ts
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

Before (vanilla Convex)

No built-in metadata system. You'd need to implement custom patterns.

After (cRPC)
const c = initCRPC
  .dataModel<DataModel>()
  .meta<{ role?: 'admin' }>()
  .create();

export const adminQuery = authQuery
  .meta({ role: 'admin' })
  .use(roleMiddleware);

Key differences:

  • Type-safe metadata with Meta type
  • Middleware accesses meta parameter
  • Chain .meta() calls with shallow merge
  • defaultMeta for default values

Next Steps

On this page