Optimizing Your tRPC Implementation in Next.js

You've set up your Next.js application with tRPC, excited about the promise of end-to-end type safety and enhanced developer experience. But now you're noticing something concerning: almost every component requires "use client" directives, your application feels sluggish, and you're wondering if you're actually taking full advantage of Next.js server-side capabilities.

If this sounds familiar, you're not alone. Many developers find themselves questioning their tRPC implementation choices, especially as their applications grow in complexity.

Understanding the tRPC-Next.js Relationship

tRPC (TypeScript Remote Procedure Call) provides end-to-end type safety between your client and server without code generation or GraphQL schemas. When combined with Next.js, it offers a powerful stack for building modern web applications.

However, this powerful combination comes with nuances that, if not properly addressed, can lead to suboptimal performance and developer frustration.

Common Pitfalls When Implementing tRPC in Next.js

Overreliance on Client-Side Rendering

One of the most frequent issues developers encounter is an excessive dependence on client-side rendering:

// This pattern appears in 80-90% of components
"use client"

function UserDashboard() {
  const { data, isLoading } = trpc.users.getProfile.useQuery();
  
  if (isLoading) return <div>Loading...</div>;
  
  return <div>{data.name}'s Dashboard</div>;
}

This approach negates many benefits of Next.js's server-side rendering capabilities, resulting in:

  • Slower initial page loads

  • Poor SEO performance

  • Unnecessary loading states visible to users

  • Potential layout shifts as data loads

As one developer noted: "I feel like 80-90% of my application relies on 'use client' to function. I'm wondering if I'm doing something wrong or if I should be fetching data on the server instead to improve performance?"

Ignoring Server Component Capabilities

Next.js 13+ introduced powerful server components, but many tRPC implementations fail to leverage them effectively. Server components can pre-render data-dependent UI on the server, eliminating client-side fetching for initial loads.

Mismanagement of SSR and SSG

Not effectively balancing Server-Side Rendering (SSR) and Static Site Generation (SSG) can lead to performance bottlenecks. Many developers underutilize Next.js's data fetching methods when working with tRPC.

Bloated Client Bundle

Heavy reliance on client-side querying can lead to larger JavaScript bundles as all the query logic and validation schemas get shipped to the client.

Performance Optimization Techniques

Let's look at practical strategies to optimize your tRPC implementation in Next.js:

1. Balance Server and Client Responsibilities

Instead of fetching all data on the client, consider server-side fetching and passing initial data through props:

// In a server component or getServerSideProps/getStaticProps
async function fetchInitialData() {
  const caller = appRouter.createCaller({ /* context */ });
  return await caller.users.getProfile();
}

// In your page
export default async function UserPage() {
  const initialData = await fetchInitialData();
  
  return <UserProfile initialData={initialData} />;
}

// In your client component
"use client"
function UserProfile({ initialData }) {
  const { data } = trpc.users.getProfile.useQuery(undefined, {
    initialData, // React Query will use this and avoid refetching
  });
  
  return <div>{data.name}'s Profile</div>;
}

As one developer shared: "I came to the conclusion that fetching server side and then passing initial data through props was the best of both worlds."

2. Utilize Server Components with tRPC

With Next.js App Router, you can create hybrid approaches:

// UserProfile.tsx - Server Component
import { ClientProfile } from './ClientProfile';

export default async function UserProfile() {
  // Server-side fetching
  const caller = appRouter.createCaller({ /* context */ });
  const userData = await caller.users.getProfile();
  
  return (
    <div>
      <h1>{userData.name}</h1>
      {/* Pass data to client component for interactive elements */}
      <ClientProfile initialData={userData} />
    </div>
  );
}

This approach leverages server components for initial data fetching while maintaining interactive client components where needed.

3. Implement Request Batching

Use tRPC's httpBatchLink to combine multiple API calls into a single network request:

import { httpBatchLink } from '@trpc/client';

const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/api/trpc',
      maxURLLength: 2083, // Limit for some browsers
    }),
  ],
});

This significantly reduces network overhead when components make multiple simultaneous queries.

4. Optimize Caching with React Query

tRPC uses Tanstack Query (formerly React Query) under the hood. Configure its caching behavior for optimal performance:

const trpc = createTRPCNext<AppRouter>({
  config() {
    return {
      links: [
        httpBatchLink({
          url: '/api/trpc',
        }),
      ],
      queryClientConfig: {
        defaultOptions: {
          queries: {
            staleTime: 5 * 60 * 1000, // 5 minutes
            cacheTime: 10 * 60 * 1000, // 10 minutes
          },
        },
      },
    };
  },
});

5. Implement Efficient Data Fetching Patterns

Avoid over-fetching by implementing pagination, filtering, and selection:

// In your router definition
const postRouter = router({
  getPaginatedPosts: publicProcedure
    .input(z.object({ 
      page: z.number().default(1),
      limit: z.number().default(10),
      filter: z.string().optional()
    }))
    .query(async ({ input }) => {
      const { page, limit, filter } = input;
      // Fetch only what's needed with pagination
      const posts = await db.post.findMany({
        where: filter ? { title: { contains: filter } } : undefined,
        skip: (page - 1) * limit,
        take: limit,
        select: {
          id: true,
          title: true,
          summary: true, // Only select needed fields
          // Omit large text fields like content
        }
      });
      
      const total = await db.post.count();
      
      return {
        posts,
        meta: { total, pages: Math.ceil(total / limit) }
      };
    }),
});

Alternatives for Complex Applications

While tRPC offers excellent type safety and developer experience, some applications may benefit from alternative approaches:

Server Actions with Zod Validation

Next.js server actions provide a native solution for server-side functions, which can be combined with Zod for type validation:

"use server"
import { z } from "zod";

const userSchema = z.object({
  name: z.string().min(2),
  email: z.string().email()
});

export async function createUser(formData: FormData) {
  const validatedData = userSchema.parse({
    name: formData.get("name"),
    email: formData.get("email")
  });
  
  // Proceed with database operations
  return await db.user.create({ data: validatedData });
}

One developer mentioned: "Use server-actions along with zod for validation. Much better than using tRPC."

Next-Safe-Action

For more robust form handling with validation, consider next-safe-action, which combines server actions with strong validation:

import { action } from "next-safe-action";
import { z } from "zod";

const createUserAction = action(
  z.object({
    name: z.string().min(2),
    email: z.string().email()
  }),
  async ({ name, email }) => {
    try {
      const user = await db.user.create({ data: { name, email } });
      return { success: true, data: user };
    } catch (error) {
      return { success: false, error: "Failed to create user" };
    }
  }
);

Hono for API Routes

Hono offers a lightweight, performant alternative for API development with Next.js:

// app/api/[[...route]]/route.ts
import { Hono } from 'hono';
import { handle } from 'hono/vercel';

const app = new Hono().basePath('/api');

app.get('/users', async (c) => {
  const users = await db.user.findMany();
  return c.json(users);
});

export const GET = handle(app);
export const POST = handle(app);

Dedicated Backend for Larger Applications

For complex applications that may outgrow tRPC's tight coupling:

  1. Consider a dedicated backend service using NestJS, Express, or Fastify

  2. Use a shared TypeScript package for interfaces and validation schemas

  3. Implement API Gateway patterns for service composition

This approach offers better separation of concerns and scalability for growing applications.

Making the Right Choice for Your Application

When deciding whether to optimize your tRPC implementation or switch to an alternative, consider:

  1. Application Complexity: tRPC excels in smaller to medium-sized applications with straightforward data requirements.

  2. Team Experience: If your team is already comfortable with REST or GraphQL, transitioning to those patterns might be more efficient than optimizing tRPC.

  3. Future Scalability: If you anticipate needing multiple clients or separating your backend services, consider whether tRPC's coupling aligns with your architecture.

  4. Performance Requirements: For applications with strict performance requirements, the overhead of client-side querying may necessitate server-side rendering approaches.

Conclusion

tRPC offers powerful type safety and developer experience when implemented correctly with Next.js. By understanding common pitfalls and implementing performance optimization techniques, you can leverage its strengths while mitigating potential drawbacks.

For complex applications, alternatives like server actions, dedicated API frameworks, or traditional REST/GraphQL approaches may provide better long-term scalability and flexibility.

Remember this wisdom from a seasoned developer: "tRPC is nice if you're building a tightly coupled frontend/backend app for rapid delivery. But it's harder to scale or separate the backend for other uses as your application grows."

By thoughtfully evaluating your application's needs and implementing the strategies outlined in this article, you can make informed decisions about optimizing your tRPC implementation or transitioning to alternatives that better serve your evolving requirements.

Additional Resources

Raymond Yeh

Raymond Yeh

Published on 06 April 2025

Get engineers' time back from marketing!

Don't let managing a blog on your site get in the way of your core product.

Wisp empowers your marketing team to create and manage content on your website without consuming more engineering hours.

Get started in few lines of codes.

Choosing a CMS
Related Posts
How to Use tRPC with Next.js 15 (App Router)

How to Use tRPC with Next.js 15 (App Router)

Struggling with type safety across web and mobile apps? Discover how tRPC with Next.js 15 delivers end-to-end type safety and superior DX. Learn the optimal monorepo setup for scalable development.

Read Full Story
Setting up tRPC with Next.js 14

Setting up tRPC with Next.js 14

Unlock the full potential of Next.js 14 by integrating it with tRPC! Follow this comprehensive guide to create type-safe, efficient applications—perfect for developers looking to optimize.

Read Full Story
TRPC & TanStack React Query - Now You Can Have Them Both!

TRPC & TanStack React Query - Now You Can Have Them Both!

tRPC + TanStack React Query integration guide: Learn to implement typesafe APIs with powerful data fetching, caching, and query invalidation in your React applications.

Read Full Story
Loading...