Middlewares
Add authorization, logging, and context transformations to procedures.
In this guide, we'll master middleware 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.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.
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.
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 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:
const c = initCRPC
.dataModel<DataModel>()
.meta<{
auth?: 'optional' | 'required';
role?: 'admin';
ratelimit?: string;
}>()
.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(ratelimit.middleware()); // 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 ORM write methods, apply it directly to the mutation chain:
// ❌ Shared middleware loses mutation-specific methods
const writeMiddleware = c.middleware(async ({ ctx, next }) => {
await ctx.orm.insert(logs).values({ ... }); // Error: insert doesn't exist
return next({ ctx });
});
// ✅ Apply directly to mutation chain
export const authMutation = c.mutation.use(async ({ ctx, next }) => {
await ctx.orm.insert(logs).values({ ... }); // 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;
});
// Plugin-owned middleware
import { ratelimit } from './plugins/ratelimit/plugin';
export const publicMutation = c.mutation.use(ratelimit.middleware());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.
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
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 };
})
);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
Next Steps
API Reference
Middleware Parameters
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 |