kitcn

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:

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

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 }) => { ... });

Next Steps

On this page