This episode opens three interception points in tRPC: server-side middleware for authentication, logging, tracing, and rate limiting; client-side links such as loggerLink, httpBatchLink, wsLink, and custom links; as well as the lifecycle hooks onError, onSuccess, and onSettled in React Query integration.

An API is rarely enough with just resolvers. You need a way to insert cross-procedure logic — checking login, logging requests, measuring time, limiting frequency. On the client side, you also need control over how requests are sent and what happens afterwards.
Episode 6 opens the three interception points that are tRPC's strength: middleware on the server, links on the client, and lifecycle hooks from React Query.
Middleware is a function that wraps a procedure. It receives ctx (context) and next, and can modify the context before passing it on:
const logger = t.middleware(async ({ path, type, ctx, next }) => {
const mulai = Date.now();
const hasil = await next();
console.log(`${type} ${path} selesai dalam ${Date.now() - mulai}ms`);
return hasil;
});t.middleware(async ({ path, type, next }) => ...) receives metadata such as the path name and procedure type, then await next() executes the continuation. Afterwards we can read the execution time.
Here is middleware that injects a user into the context — the basic pattern for authentication that will be expanded in episode 11:
export const isLoggedIn = t.middleware(({ ctx, next }) => {
const user = ctx.req?.headers.get("authorization");
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: { ...ctx, userId: user },
});
});
export const protectedProcedure = publicProcedure.use(isLoggedIn);publicProcedure.use(isLoggedIn) produces a new procedure that runs the middleware every time it is called. Because the middleware returns next({ ctx: { ...ctx, userId } }), every procedure using it can access ctx.userId.
Middleware runs in layers according to the order of .use(). For rate limiting, for example, run the limit check before other heavy validation. Combining several middleware lets a single procedure use authentication, logging, and rate limiting at once without changing the resolver.
Links are the request pipeline on the client side. Each link receives an operation and can forward it to the next link:
import { createTRPCProxyClient, httpBatchLink, loggerLink, splitLink, wsLink } from "@trpc/client";
const client = createTRPCProxyClient<AppRouter>({
links: [
loggerLink({ enabled: () => process.env.NODE_ENV === "development" }),
splitLink({
condition: (op) => op.type === "subscription",
true: wsLink({ url: "ws://localhost:3000" }),
false: httpBatchLink({ url: "http://localhost:3000/trpc" }),
}),
],
});Three main links you must understand:
loggerLink logs every request and response — perfect for development.httpBatchLink combines multiple queries into a single HTTP request.wsLink uses WebSocket, required for subscriptions.splitLink({ condition, true, false }) picks a link based on a condition — here subscriptions go through WebSocket, everything else through HTTP batching.
When you need special logic, create your own link. Every link is a function that receives next and returns another function that receives an operation:
import type { TRPCLink } from "@trpc/client";
const headerLink: TRPCLink<AppRouter> = () => {
return ({ op, next }) => {
op.context.headers = { ...op.context.headers, "X-App": "belajar-trpc" };
return next(op);
};
};The headerLink custom link injects an additional header into every operation before forwarding it. Because links can be chained and patterned like middleware, you can build a very flexible request pipeline.
When using @trpc/react-query, every hook accepts React Query options. The three most useful lifecycle callbacks:
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
trpc.user.list.invalidate();
},
onError: (error) => {
toast(error.message);
},
onSettled: () => {
setSubmitting(false);
},
});onSuccess is called when the mutation succeeds — for example invalidating the list cache.onError is called when it fails — here it displays an error message to the user.onSettled is always called, whether success or failure — ideal for turning off a loading state.Queries also have onError. When a request fails, you can show a fallback or log it:
const { data } = trpc.user.list.useQuery(undefined, {
onError: (error) => console.error("Gagal memuat user", error.message),
});Note: useQuery(undefined, { onError }) — the first argument is the input (if the procedure accepts no input, send undefined), the second argument is the hook options.
Tip
Combine all three: use onSettled for things that always happen, onSuccess for actions that depend on success like invalidate, and onError for user-visible failure handling.
Episode 6 gives you full control over the request flow: middleware inserts logic on the server, links shape the request pipeline on the client, and lifecycle hooks respond to the results in React.
Key takeaways:
next() forwards the execution.publicProcedure.use(middleware) produces a procedure with new behavior.loggerLink, httpBatchLink, and wsLink handle common request needs.splitLink picks a link based on conditions like the operation type.onSuccess, onError, and onSettled close the lifecycle loop on the client.In the next episode, episode 7, we will discuss tRPC configuration and environment variables — managing base URLs, API keys, and development mode, arranging shared configuration between client and server, and applying conditional loggerLink for development and production.