0Pricing

Beyond the Basics: Advanced tRPC Techniques for Real-World Applications

Dive into advanced tRPC techniques like enhanced context, custom transformers, and explore its power in monorepos and integrations with external services, showcasing how it scales for complex, real-world applications.

T
tRPC End-to-End Type Safe APIs · 7 min read · 1,479 words

Welcome back, CoddyKit learners! In our journey through the world of tRPC, we've explored its foundational benefits, best practices, and how to sidestep common pitfalls. Now, it's time to elevate our understanding and uncover how tRPC truly shines in more complex, real-world scenarios. This post, the fourth in our series, focuses on advanced techniques and powerful use cases that demonstrate tRPC's robustness beyond simple CRUD operations.

Unlocking Deeper Potential with tRPC

While tRPC simplifies API development dramatically, its true power emerges when you leverage its extensible architecture for sophisticated requirements. Let's delve into some advanced patterns.

1. Context Enhancements and Robust Middleware

The tRPC context is your gateway to request-specific data, accessible across all your procedures. While we've likely used it for basic user authentication, its capabilities extend much further. You can enrich the context with:

  • Tenant Information: For multi-tenant applications, inject the current tenant ID.
  • Database Connections: Manage transaction-scoped database connections for specific requests.
  • Feature Flags: Dynamically enable/disable features based on user or tenant.
  • Request-scoped Services: Instantiate services (e.g., email sender, payment gateway client) that are tied to the current request lifecycle.

Coupled with enhanced context, tRPC's middleware system provides a powerful interception layer. Beyond simple authentication, you can implement:

  • Role-Based Access Control (RBAC): Check user roles and permissions for specific procedures.
  • Logging and Metrics: Capture detailed request information, execution times, and errors.
  • Rate Limiting: Prevent abuse by limiting the number of requests from a single client.
  • Input Validation & Transformation: Pre-process or validate inputs before they reach your business logic (though tRPC's input validation is often sufficient).

Example: Advanced Authorization Middleware

Let's imagine a scenario where we need to ensure a user has specific permissions to access a procedure, and also logs the access.

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { OpenApiMeta } from 'trpc-openapi';

interface MyUser { id: string; name: string; roles: string[]; }

// A more sophisticated context builder
export const createContext = async (opts: { req: Request }) => {
  // In a real app, you'd decode a JWT or session cookie here
  const user: MyUser | null = await getUserFromRequest(opts.req);
  return { user, requestId: crypto.randomUUID() };
};

type Context = Awaited<ReturnType<typeof createContext>>;

export const t = initTRPC.context<Context>().meta<OpenApiMeta>().create();

// Custom middleware to check user roles
const enforceRole = (role: string) => t.middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
  }
  if (!ctx.user.roles.includes(role)) {
    throw new TRPCError({ code: 'FORBIDDEN', message: `Requires role: ${role}` });
  }
  console.log(`[${ctx.requestId}] User ${ctx.user.name} access granted for role ${role}.`);
  return next({ ctx: { ...ctx, user: ctx.user } }); // Pass the user down
});

// Public, protected, and admin procedures
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(enforceRole('user')); // Everyone logged in is a 'user'
export const adminProcedure = t.procedure.use(enforceRole('admin'));

// In your router:
// export const appRouter = t.router({
//   post: protectedProcedure
//     .input(z.object({ id: z.string() }))
//     .query(({ input }) => { /* ... */ }),
//   adminDashboard: adminProcedure
//     .query(() => { /* ... */ }),
// });

This pattern allows for fine-grained control and separation of concerns, keeping your business logic clean and focused.

2. Custom Transformers for Data Serialization

By default, tRPC uses JSON.stringify and JSON.parse for data serialization. While robust, JSON has limitations, especially with types like Date, BigInt, Map, Set, or custom class instances, which are not preserved during serialization. For advanced scenarios, tRPC allows you to specify custom data transformers.

The most popular solution is SuperJSON, which seamlessly handles these complex types while maintaining end-to-end type safety.

Why use SuperJSON?

  • Date Objects: Transmit actual Date objects, not just ISO strings.
  • BigInt: Essential for handling large numbers common in database IDs or financial calculations.
  • Maps & Sets: Preserve these data structures.
  • Custom Classes: Serialize and deserialize instances of your custom classes (with some caveats).
  • Error Objects: Better error serialization across the wire.

Implementing SuperJSON

// server/trpc.ts
import { initTRPC } from '@trpc/server';
import superjson from 'superjson'; // <-- Import superjson

export const t = initTRPC.context<Context>().create({
  transformer: superjson, // <-- Apply superjson here
});

// client/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server/routers/_app';
import superjson from 'superjson'; // <-- Import superjson

export const trpc = createTRPCProxyClient<AppRouter>({
  transformer: superjson, // <-- Apply superjson here on the client side too
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/api/trpc',
    }),
  ],
});

// In your router, you can now return/receive Dates, BigInts, etc., directly
// exampleRouter.query('getEvent', {
//   resolve() {
//     return { id: 1, name: 'CoddyKit Workshop', date: new Date(), price: 99.99n };
//   },
// });

By using SuperJSON, your tRPC procedures can work with richer data types directly, simplifying your code and eliminating manual serialization/deserialization logic.

3. Real-World Use Case: Monorepo Strategies with tRPC

tRPC truly excels in a monorepo environment, where client and server code, along with shared types, live in the same repository. This setup naturally leverages tRPC's core strength: sharing types directly.

Benefits in a Monorepo:

  • Single Source of Truth: Your API types are defined once and consumed by both frontend and backend.
  • Instant Type Updates: Changes to a backend procedure's input/output types immediately propagate to the frontend, caught by TypeScript at compile time.
  • Simplified Deployment: Often, you can deploy your client and server together, ensuring compatibility.
  • Code Sharing: Share validation schemas (e.g., Zod), utility functions, and even parts of your tRPC routers between different services or applications within the monorepo.

Monorepo Structure Example:

/my-monorepo
├── packages/
│   ├── client/          # Next.js, React, Vue app
│   │   ├── src/
│   │   │   ├── components/
│   │   │   └── utils/
│   │   │       └── trpc.ts # tRPC client setup, imports `@repo/api`
│   │   ├── package.json
│   │   └── tsconfig.json
│   │
│   ├── server/          # Node.js, Express, Next.js API routes
│   │   ├── src/
│   │   │   ├── api/
│   │   │   │   └── trpc.ts # tRPC router setup, imports `@repo/api`
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   │
│   └── api/             # Shared tRPC definitions package
│       ├── src/
│       │   ├── trpc.ts  # initTRPC, createContext, public/protected procedures
│       │   └── routers/
│       │       ├── auth.ts
│       │       ├── post.ts
│       │       └── _app.ts # Merges all sub-routers
│       ├── package.json
│       └── tsconfig.json
├── package.json       # Monorepo root (e.g., using pnpm, yarn workspaces, turborepo)

In this setup, the @repo/api package contains the core tRPC infrastructure and your API routers. Both your client and server packages depend on and import from @repo/api, ensuring a perfectly synchronized type system across your entire application stack.

4. Real-World Use Case: Integrating with External Services & Event-Driven Architectures

While tRPC is primarily about direct client-server communication, your tRPC procedures often need to interact with external services or trigger asynchronous background tasks. tRPC acts as an excellent, type-safe gateway for these integrations.

Examples:

  • Payment Gateway Integration: A createCheckoutSession tRPC procedure might call out to Stripe's API, create a session, and return the session ID to the client.
  • Email/SMS Notifications: A sendWelcomeEmail procedure could dispatch a message to a queue (e.g., RabbitMQ, Kafka) or directly call a transactional email service (e.g., SendGrid, Mailgun).
  • Third-Party APIs: Fetching data from a weather API, currency exchange API, or social media API within a tRPC procedure.
  • Long-Running Tasks: For tasks that take a long time (e.g., video encoding, report generation), a tRPC procedure can initiate the task and return an immediate response, while the actual processing happens asynchronously via a job queue.

Integrating with a Message Queue (Conceptual)

// server/routers/notifications.ts
import { z } from 'zod';
import { publicProcedure, t } from '../trpc';
import { messageQueueService } from '../services/messageQueueService'; // Your message queue client

export const notificationRouter = t.router({
  sendWelcomeEmail: publicProcedure
    .input(z.object({
      userId: z.string(),
      email: z.string().email(),
      name: z.string(),
    }))
    .mutation(async ({ input }) => {
      console.log(`Received request to send welcome email to ${input.email}`);
      
      // Dispatch an event to a message queue for asynchronous processing
      await messageQueueService.publish('email_queue', {
        type: 'WELCOME_EMAIL',
        payload: { userId: input.userId, email: input.email, name: input.name },
      });

      return { success: true, message: 'Email dispatch initiated' };
    }),
});

In this pattern, the tRPC procedure handles the immediate client request and ensures type safety for the input. The actual heavy lifting or interaction with external systems is then delegated, keeping your API responsive and scalable. The client still receives a type-safe response indicating the initiation of the task.

Conclusion

As you can see, tRPC is far more than just a simple API layer. Its flexible architecture, combined with advanced techniques like sophisticated context management, powerful middleware, custom transformers, and its natural fit for monorepos, makes it an incredibly robust choice for building complex, real-world applications. By leveraging these advanced patterns, you can build scalable, maintainable, and highly type-safe systems that stand the test of time.

Stay tuned for our final post in this series, where we'll look into the future trends and the evolving ecosystem around tRPC!

ProgrammingTutorialCoddyKit

Enjoyed this article?

Explore more tutorials and insights to level up your coding skills.

Browse All Articles →