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 Case | What It Does |
|---|---|
| Authentication | Check if user is logged in |
| Authorization | Check if user has permission |
| Context Extension | Add user, userId to context |
| Rate Limiting | Throttle requests |
| Logging | Track 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.
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:
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();
});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:
| Property | Description |
|---|---|
ctx | Current context |
meta | Procedure metadata |
next | Function to call next middleware/handler |
input | Validated input (unknown before .input(), typed after) |
getRawInput | Function 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:
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 workBefore .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:
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:
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:
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 limitingImportant: 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:
// 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
CRPCErrorfor typed errors with codes - Chain multiple
.use()calls for composable middleware - Access
metafor procedure-level configuration - Use
c.middleware()for standalone, reusable middleware