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 RenderingOne 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 CapabilitiesNext.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 SSGNot 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 BundleHeavy 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:
Consider a dedicated backend service using NestJS, Express, or Fastify
Use a shared TypeScript package for interfaces and validation schemas
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:
Application Complexity: tRPC excels in smaller to medium-sized applications with straightforward data requirements.
Team Experience: If your team is already comfortable with REST or GraphQL, transitioning to those patterns might be more efficient than optimizing tRPC.
Future Scalability: If you anticipate needing multiple clients or separating your backend services, consider whether tRPC's coupling aligns with your architecture.
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.