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
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.
.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 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);Procedure Info
Middleware also receives a server-only procedure object for logging and tracing.
procedure.typeis always available.- Standard
export constqueries, mutations, and actions inferprocedure.nameautomatically when they are built from your appgenerated/serverhelper, 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, andprocedure.pathfrom the route automatically.
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);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:
// 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:
| 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 |