BETTER-CONVEX

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.

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/meta.ts containing metadata for all procedures. The client uses this to determine function types and auth requirements:

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

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

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

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 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:

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

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

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

convex/lib/crpc.ts
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 Meta type
  • Middleware accesses meta parameter
  • Chain .meta() calls with shallow merge
  • defaultMeta for default values

Next Steps

On this page