tRPC Middleware
Understanding the middleware chain and request processing pipeline
Overview
Every tRPC request passes through a series of middleware functions that handle cross-cutting concerns like bot detection, internationalization, rate limiting, timing, and error handling. These middleware functions execute in a specific order to ensure consistent behavior across all API endpoints.
Middleware Execution Order
The middleware chain processes requests in this order:
- Bot Detection - Blocks identified bots on protected routes
- i18n - Sets up localized Zod error messages
- Rate Limiting - Applies request limits based on configuration
- Timing - Logs procedure execution time
- Error Catcher - Translates and formats errors
export const publicProcedure = trpc.procedure
.use(botDetectionMiddleware)
.use(i18nMiddleware)
.use(rateLimitMiddleware)
.use(timingMiddleware)
.use(errorCatcherMiddleware);Each middleware can:
- Inspect the request context
- Modify the context for subsequent middleware
- Short-circuit the request (e.g., return error for bots)
- Execute logic before or after the next middleware
1. Bot Detection Middleware
Identifies and blocks automated bots from accessing protected endpoints.
Purpose
- Prevent automated scraping
- Reduce spam and abuse
- Protect sensitive endpoints
- Allow verified bots (like search engines)
Implementation
const botDetectionMiddleware = trpc
.middleware(async ({ next, meta, ctx, input, path }) => {
// Block bots if configured in meta
if (meta?.blockBots && await getFeatureFlag("disableBotIdCheck") !== true) {
const verification = await checkBotId();
if (verification.isBot) {
console.warn("trpc", "Bot detected: is good bot?", verification.isVerifiedBot);
throw new TRPCError({
code: "FORBIDDEN",
message: "Access denied: Bot detected",
});
}
}
return next({ ctx, input });
});Usage
Enable bot detection per-procedure using meta:
export const sensitiveRouter = createTRPCRouter({
getData: publicProcedure
.meta({ blockBots: true })
.query(async () => {
// This endpoint blocks bots
return { data: "sensitive" };
}),
});Configuration
- Global toggle - Use
disableBotIdCheckfeature flag to disable - Per-procedure - Set
blockBots: truein meta to enable - Verified bots - Search engine crawlers are allowed through
2. i18n Middleware
Sets up internationalization context for the request, including localized error messages.
Purpose
- Provide localized Zod validation errors
- Set error message language based on user locale
- Ensure consistent i18n across server procedures
Implementation
const i18nMiddleware = trpc
.middleware(async ({ next, ctx, input }) => {
// Set Zod error map for server-side validation errors
locaZodErrorMap(ctx.locale);
return next({ ctx, input });
});How It Works
- Extracts locale from context (already set during tRPC context creation)
- Applies locale-specific error messages to Zod validation
- All subsequent Zod schema validations use the correct language
Example
// Schema validation
const userSchema = z.object({
email: z.string().email(),
age: z.number().min(18),
});
// If user's locale is "es" (Spanish), errors are in Spanish:
// "El email no es válido"
// If locale is "en" (English):
// "Invalid email address"The error messages are automatically localized based on the user's preferred language.
3. Rate Limiting Middleware
Prevents abuse by limiting the number of requests per user/IP address.
Purpose
- Prevent API abuse
- Protect against DDoS attacks
- Ensure fair resource usage
- Different limits for different operation types
Implementation
const rateLimitMiddleware = trpc
.middleware(async ({ next, meta, ctx, path, type }) => {
// Skip rate limiting if disabled globally
if (!Config.RateLimit.ENABLED) {
return next({ ctx });
}
// Skip rate limiting if disabled in meta
if (meta?.rateLimit === false) {
return next({ ctx });
}
// Get rate limit config
const rateLimitKey = meta?.rateLimit || (type === "mutation" ? "MUTATION" : "QUERY");
const config = RATE_LIMIT_CONFIGS[rateLimitKey];
if (!config) {
console.warn(`trpc: Rate limit config not found for key: ${rateLimitKey}`);
return next({ ctx });
}
// Extract IP address from request
const ip = getIpAddress(ctx.request);
// Generate rate limit key
const key = generateRateLimitKey({
ip,
userId: ctx.user?.id,
path,
});
// Apply rate limiting
const secondsBeforeNext = await applyRateLimit(key, config, {
configKey: rateLimitKey,
ip,
userId: ctx.user?.id,
path,
});
if (secondsBeforeNext && secondsBeforeNext > 0) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: ctx.t("trpcErrors.TOO_MANY_REQUESTS", { seconds: secondsBeforeNext }),
});
}
return next({ ctx });
});Rate Limit Configurations
Defined in src/lib/rate-limit/rate-limiter.ts:
| Type | Requests | Window | Use Case |
|---|---|---|---|
QUERY | 100 | 1 minute | Read operations |
MUTATION | 30 | 1 minute | Write operations |
AUTH | 10 | 5 minutes | Login/signup |
PAYMENT | 5 | 1 minute | Payment operations |
Usage
Specify rate limit type in procedure meta:
export const userRouter = createTRPCRouter({
// Use default QUERY rate limit (100/min)
list: publicProcedure
.query(async () => {
return await userOperations.list();
}),
// Use MUTATION rate limit (30/min)
create: publicProcedure
.meta({ rateLimit: "MUTATION" })
.input(createUserSchema)
.mutation(async ({ input }) => {
return await userOperations.create(input);
}),
// Disable rate limiting
health: publicProcedure
.meta({ rateLimit: false })
.query(() => ({ status: "ok" })),
});Rate Limit Key Strategy
The rate limit key combines:
- IP address - For unauthenticated requests
- User ID - For authenticated requests
- Procedure path - Optional per-endpoint limiting
// Example keys:
// "rate_limit:ip:192.168.1.1:users.list"
// "rate_limit:user:abc123:organizations.create"4. Timing Middleware
Measures and logs the execution time of each procedure.
Purpose
- Monitor API performance
- Identify slow endpoints
- Debug performance issues
- Track performance over time
Implementation
const timingMiddleware = trpc
.middleware(async ({ next, path, ctx, input }) => {
const start = Date.now();
const result = await next({ ctx, input });
const end = Date.now();
console.log(`⏰ [TRPC] ${path} took ${end - start}ms`);
return result;
});Output
Console logs show timing for each request:
⏰ [TRPC] users.list took 45ms
⏰ [TRPC] organizations.getById took 12ms
⏰ [TRPC] subscriptions.create took 234msBest Practices
- Monitor for procedures taking > 1000ms
- Investigate and optimize slow endpoints
- Consider caching for frequently accessed data
- Use database indexes for common queries
5. Error Catcher Middleware
Translates errors into user-friendly, localized messages.
Purpose
- Provide consistent error format
- Translate errors to user's language
- Preserve Zod validation errors for forms
- Convert technical errors to user-friendly messages
Implementation
const errorCatcherMiddleware = trpc
.middleware(async ({ next, ctx, input }) => {
const resp = await next({ ctx, input });
// Customize error if tRPC captured it in the response
if (!resp.ok) {
// Let Zod validation errors propagate (for form handling)
if (resp.error.code === "BAD_REQUEST" && resp.error.cause instanceof ZodError) {
return resp;
}
const error = resp.error;
// Get the underlying error (could be nested in TRPCError cause)
const underlyingError = error instanceof TRPCError
? (error.cause ?? error)
: error;
// Translate the underlying error
const errorTranslation = translateError(underlyingError, {
t: ctx.t,
captureScope: "trpc"
});
// Return success with error message
return {
ok: true,
data: {
success: false,
message: errorTranslation.message,
} as any,
marker: "middlewareMarker" as any,
};
}
return resp;
});Error Flow
- Zod validation errors - Pass through unchanged (used by forms)
- Custom errors - Translated to user's language
- Unknown errors - Converted to generic message
- Sentry integration - Errors automatically logged
Custom Error Classes
Define custom errors in src/lib/errors/:
import { NotFoundError, ValidationError, UnauthorizedError } from "@/lib/errors";
// In a procedure
if (!user) {
throw new NotFoundError("User");
// Becomes: "User not found"
}
if (!user.emailVerified) {
throw new ValidationError("Email must be verified");
}
if (user.id !== ctx.user?.id) {
throw new UnauthorizedError();
// Becomes: "Unauthorized access"
}Error Response Format
{
success: false,
message: "User not found", // Localized message
data: {
zodError: null, // Only present for validation errors
}
}Maintenance Mode
A special middleware layer can block all mutations during maintenance:
.use(async ({ ctx, next, signal, meta, path, input, type }) => {
// Check if flag to disable all submits is enabled
if (
meta?.type === "mutation" &&
!ALWAYS_ALLOWED_PROCEDURES.includes(path) &&
(await getFeatureFlag("disableSubmit")) === true
) {
throw new TRPCError({
code: "FORBIDDEN",
message: ctx.t("miscellaneous.maintenanceMode.featureDisabled"),
});
}
return next({ ctx, input });
})Always-Allowed Procedures
Certain critical procedures bypass maintenance mode:
const ALWAYS_ALLOWED_PROCEDURES: string[] = [
"auth.login",
"auth.logout",
"auth.refreshSession",
"auth.verifyEmail",
"auth.resendVerification",
"featureFlags.upsert"
];Creating Custom Middleware
You can create custom middleware for your specific needs:
const customMiddleware = trpc.middleware(async ({ next, ctx, meta }) => {
// Before procedure execution
console.log("Before:", meta);
// Execute next middleware
const result = await next({ ctx });
// After procedure execution
console.log("After:", result);
return result;
});
// Use in a procedure
export const customProcedure = publicProcedure
.use(customMiddleware)
.query(async () => {
return { data: "Hello" };
});Best Practices
- Keep middleware fast - Slow middleware affects all requests
- Use meta for configuration - Don't hardcode logic in middleware
- Log appropriately - Too much logging can impact performance
- Handle errors gracefully - Don't let middleware crash
- Test middleware - Unit test each middleware function
- Document behavior - Explain what each middleware does
Related Documentation
- tRPC Guide - Setting up tRPC routers and procedures
- Error Handling - Custom error classes and translation
- Rate Limiting - Detailed rate limit configuration
- i18n - Internationalization setup
Source Code
See the complete implementation in:
- src/trpc/procedures/trpc.ts - Middleware definitions
- src/lib/rate-limit/rate-limiter.ts - Rate limit configs
- src/lib/errors/translate-error.ts - Error translation