BETTER-CONVEX

Middlewares

Add authorization, logging, and context transformations to procedures.

In this guide, we'll master middleware - the secret sauce for reusable backend logic. You'll learn to check authentication, transform context, log requests, and chain multiple middleware together.

Overview

Middleware wraps procedure invocation. It runs before (and optionally after) your handler, letting you:

Use CaseWhat It Does
AuthenticationCheck if user is logged in
AuthorizationCheck if user has permission
Context ExtensionAdd user, userId to context
Rate LimitingThrottle requests
LoggingTrack request duration

The key rule: middleware must call next() and return its result.

Authorization

Let's start with the most common use case - checking if a user is authorized before the procedure runs.

convex/lib/crpc.ts
export const authQuery = c.query.use(async ({ ctx, next }) => {
  const user = await getSessionUser(ctx);
  if (!user) {
    throw new CRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: { ...ctx, user, userId: user.id },
  });
});

Now when you use authQuery, you're guaranteed to have ctx.user and ctx.userId:

convex/functions/posts.ts
export const myPosts = authQuery
  .query(async ({ ctx }) => {
    // ctx.user and ctx.userId are guaranteed to exist
    return ctx.db.query('posts')
      .withIndex('by_author', q => q.eq('authorId', ctx.userId))
      .collect();
  });
convex/functions/posts.ts
export const myPosts = authQuery
  .query(async ({ ctx }) => {
    // ctx.user and ctx.userId are guaranteed to exist
    return ctx.table('posts', 'by_author', q =>
      q.eq('authorId', ctx.userId)
    );
  });

Middleware Signature

Every middleware receives an object with these properties:

PropertyDescription
ctxCurrent context
metaProcedure metadata
nextFunction to call next middleware/handler
inputValidated input (unknown before .input(), typed after)
getRawInputFunction to get raw input before validation

Here's the basic pattern:

.use(async ({ ctx, meta, next, input }) => {
  // Do something before
  const result = await next({ ctx });
  // Do something after (optional)
  return result;
})

Context Extension

Middleware can add or transform context properties. The new context is fully type-safe - TypeScript knows exactly what's available:

convex/lib/crpc.ts
export const authQuery = c.query.use(async ({ ctx, next }) => {
  const user = await getSessionUser(ctx);
  if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' });

  // Add user and userId to context
  return next({
    ctx: {
      ...ctx,
      user,
      userId: user.id,
    },
  });
});

// In procedures, ctx.user is non-null and typed!
export const profile = authQuery
  .query(async ({ ctx }) => {
    return ctx.user; // Type: User, not User | null
  });

Input Access

Middleware placed after .input() receives typed input. Use it to fetch related data:

export const queryWithProject = publicQuery
  .input(z.object({ projectId: zid('projects'), name: z.string() }))
  .use(async ({ ctx, input, next }) => {
    const project = await ctx.db.get(input.projectId);
    // Add to context, enrich input, or override fields
    return next({ ctx: { ...ctx, project }, input: { ...input, name: input.name.trim() } });
  })
  .query(async ({ ctx, input }) => input.project); // both work

Before .input(), input is unknown. Use getRawInput() for raw input before validation.

Using Meta

Sometimes different procedures need different behavior from the same middleware. That's where metadata comes in.

First, define your meta type when initializing cRPC:

convex/lib/crpc.ts
type Meta = {
  auth?: 'optional' | 'required';
  role?: 'admin';
  rateLimit?: string;
};

const c = initCRPC
  .dataModel<DataModel>()
  .meta<Meta>()
  .create({ ... });

Now middleware can read metadata and act accordingly:

convex/lib/crpc.ts
const roleMiddleware = c.middleware<{ user: { isAdmin: boolean } }>(
  ({ ctx, meta, next }) => {
    if (meta.role === 'admin' && !ctx.user.isAdmin) {
      throw new CRPCError({ code: 'FORBIDDEN' });
    }
    return next({ ctx });
  }
);

// Set metadata when building procedure variants
export const adminQuery = authQuery
  .meta({ role: 'admin' })
  .use(roleMiddleware);

Chaining Middleware

Chain multiple .use() calls to compose behavior. They execute in order:

convex/lib/crpc.ts
export const authMutation = c.mutation
  .meta({ auth: 'required' })
  .use(authMiddleware)      // 1. Check auth, add user to ctx
  .use(roleMiddleware)      // 2. Check role if meta.role set
  .use(rateLimitMiddleware); // 3. Apply rate limiting

Important: Order matters! Later middleware can access context from earlier middleware. Put auth first so role checks can access ctx.user.

Sharing Middleware

Queries and mutations have different context types. To share middleware between them, use a loose type constraint:

// ✅ Use loose type constraint for shared middleware
const roleMiddleware = c.middleware<object>(({ ctx, meta, next }) => {
  // Access user via type assertion
  const user = (ctx as { user?: { isAdmin?: boolean } }).user;
  if (meta.role === 'admin' && !user?.isAdmin) {
    throw new CRPCError({ code: 'FORBIDDEN' });
  }
  return next({ ctx });
});

// Apply to both query and mutation chains
export const authQuery = c.query
  .use(authMiddleware)
  .use(roleMiddleware);

export const authMutation = c.mutation
  .use(authMiddleware)
  .use(roleMiddleware);

For middleware that needs ctx.db write methods, apply it directly to the mutation chain:

// ❌ Shared middleware loses mutation-specific methods
const dbMiddleware = c.middleware(async ({ ctx, next }) => {
  await ctx.db.insert('logs', { ... }); // Error: insert doesn't exist
  return next({ ctx });
});

// ✅ Apply directly to mutation chain
export const authMutation = c.mutation.use(async ({ ctx, next }) => {
  await ctx.db.insert('logs', { ... }); // Works!
  return next({ ctx });
});

Reusable Middleware

Create standalone middleware with c.middleware() for reuse across your codebase:

convex/lib/crpc.ts
// Standalone middleware
const logMiddleware = c.middleware(async ({ ctx, next }) => {
  const start = Date.now();
  const result = await next({ ctx });
  console.log(`Request took ${Date.now() - start}ms`);
  return result;
});

// Type-constrained middleware
const rateLimitMiddleware = c.middleware<
  MutationCtx & { user?: { id: string; plan: string } | null }
>(async ({ ctx, meta, next }) => {
  await rateLimitGuard({
    ...ctx,
    rateLimitKey: meta.rateLimit ?? 'default',
    user: ctx.user ?? null,
  });
  return next({ ctx });
});

Extending with .pipe()

Want to extend existing middleware? Use .pipe():

const authMiddleware = c.middleware(async ({ ctx, next }) => {
  const user = await getSessionUser(ctx);
  if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' });
  return next({ ctx: { ...ctx, user } });
});

// Extend auth with admin check
const adminMiddleware = authMiddleware.pipe(({ ctx, next }) => {
  if (!ctx.user.isAdmin) {
    throw new CRPCError({ code: 'FORBIDDEN' });
  }
  return next({ ctx });
});

Common Patterns

Here are battle-tested middleware patterns you can copy into your project.

Auth Required

With Better Auth, use getSession() to retrieve the session and fetch the user:

import { getSession } from 'better-convex/auth';
import { CRPCError } from 'better-convex/server';

export const authQuery = c.query.use(async ({ ctx, next }) => {
  const session = await getSession(ctx);
  if (!session) {
    throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
  }

  const user = await ctx.db.get(session.userId);
  if (!user) {
    throw new CRPCError({ code: 'UNAUTHORIZED', message: 'User not found' });
  }

  return next({
    ctx: {
      ...ctx,
      user: { id: user._id, ...user },
      userId: user._id,
    },
  });
});

Auth Optional

For queries that work with or without authentication:

export const optionalAuthQuery = c.query.use(async ({ ctx, next }) => {
  const session = await getSession(ctx);
  if (!session) {
    return next({ ctx: { ...ctx, user: null, userId: null } });
  }

  const user = await ctx.db.get(session.userId);
  if (!user) {
    return next({ ctx: { ...ctx, user: null, userId: null } });
  }

  return next({
    ctx: {
      ...ctx,
      user: { id: user._id, ...user },
      userId: user._id,
    },
  });
});

Rate Limiting

const rateLimitMiddleware = c.middleware<MutationCtx>(
  async ({ ctx, meta, next }) => {
    await rateLimitGuard({
      ...ctx,
      rateLimitKey: meta.rateLimit ?? 'default',
    });
    return next({ ctx });
  }
);

export const publicMutation = c.mutation
  .use(rateLimitMiddleware);

Logging

const logMiddleware = c.middleware(async ({ ctx, next }) => {
  const start = Date.now();
  const result = await next({ ctx });
  console.log(`Duration: ${Date.now() - start}ms`);
  return result;
});

Migrate from Convex

If you're coming from convex-helpers, here's what changes.

What stays the same

  • Middleware runs before the handler
  • Can transform context and add properties

What's new

Before (convex-helpers):

import { customQuery, customCtx } from 'convex-helpers/server/customFunctions';

const authQuery = customQuery(query,
  customCtx(async (ctx) => {
    const user = await getUser(ctx);
    if (!user) throw new Error('Unauthorized');
    return { user };
  })
);

After (cRPC):

const authQuery = c.query.use(async ({ ctx, next }) => {
  const user = await getUser(ctx);
  if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' });
  return next({ ctx: { ...ctx, user } });
});

Key differences:

  • Call next({ ctx }) instead of returning context directly
  • Use CRPCError for typed errors with codes
  • Chain multiple .use() calls for composable middleware
  • Access meta for procedure-level configuration
  • Use c.middleware() for standalone, reusable middleware

Next Steps

On this page