kitcn

Middlewares

Add authorization, logging, and context transformations to procedures.

Middleware wraps procedure invocation. It runs before (and optionally after) your handler. The key rule: middleware must call next() and return its result.

Authorization

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.orm.query.posts.findMany({
      where: { authorId: ctx.userId },
      limit: 50,
    });
  });

Middleware Signature

Every middleware receives an object — see Middleware Parameters in API Reference for the full property list.

.use(async ({ ctx, meta, procedure, 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.

The authQuery above demonstrates this: it calls next({ ctx: { ...ctx, user, userId: user.id } }), and downstream handlers see ctx.user typed as 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: z.string(), name: z.string() }))
  .use(async ({ ctx, input, next }) => {
    const project = await ctx.orm.query.projects.findFirst({
      where: { id: 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
const c = initCRPC
  .dataModel<DataModel>()
  .meta<{
    auth?: 'optional' | 'required';
    role?: 'admin';
    ratelimit?: string;
  }>()
  .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);

Procedure Info

Middleware also receives a server-only procedure object for logging and tracing.

  • procedure.type is always available.
  • Standard export const queries, mutations, and actions infer procedure.name automatically when they are built from your app generated/server helper, which is the normal setup path.
  • Use .name('module:function') to override the inferred name or cover unusual export shapes.
  • HTTP routes derive procedure.name, procedure.method, and procedure.path from the route automatically.

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(ratelimit.middleware()); // 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);

Shared middleware keeps mutation writer types when you apply it to a mutation chain. If the middleware itself performs writes, make it mutation-only:

import type { MutationCtx } from '../functions/generated/server';

const writeMiddleware = c.middleware<MutationCtx>(async ({ ctx, next }) => {
  await ctx.orm.insert(logs).values({ ... });
  return next({ ctx });
});

export const authMutation = c.mutation.use(writeMiddleware);

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, procedure, next }) => {
  const start = Date.now();
  try {
    return await next({ ctx });
  } finally {
    console.log(`[${procedure.name ?? procedure.type}] ${Date.now() - start}ms`);
  }
});

export const myPosts = c.query
  .use(logMiddleware)
  .query(async ({ ctx }) => {
    return ctx.orm.query.posts.findMany({
      limit: 50,
    });
  });

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 common middleware patterns.

Auth Required

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

import { getSession } from 'kitcn/auth';
import { CRPCError } from 'kitcn/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.orm.query.user.findFirst({
    where: { id: 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.orm.query.user.findFirst({
    where: { id: 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

import { ratelimit } from './plugins/ratelimit/plugin';

export const publicMutation = c.mutation.use(ratelimit.middleware());

Logging

See logMiddleware in Reusable Middleware above.

Next Steps

API Reference

Middleware Parameters

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

On this page