I have a confession to make.
For 6 months, I maintained two separate TypeScript codebases. My Next.js frontend had a types/api.ts file with 200+ lines of interfaces. My Express backend had an almost identical file. Every time I changed an API endpoint, I had to update both files. Every time I forgot, users got runtime errors.
I tried everything. I used Zod for runtime validation. I wrote integration tests. I even considered GraphQL (but the setup scared me). Nothing solved the core problem: My frontend and backend spoke different languages at compile time, even though they were both TypeScript.
The "Professional Developer" Tax
Here is what my workflow looked like with REST APIs:
- 01.Write an Express route:
app.post('/api/users', ...) - 02.Define the request body type in the backend
- 03.Copy that type definition to my frontend
- 04.Write a fetch wrapper with the "correct" types
- 05.Pray that I didn't make a typo
- 06.Wait for a bug report from users when I inevitably did
This is not how TypeScript is supposed to work. TypeScript promises compile-time safety. But the moment my data crosses the network, I lose all guarantees.
I am a student building production-grade apps for hackathons and my portfolio. I cannot afford to waste hours debugging type mismatches that TypeScript should have caught.
The RPC Revelation: Your Backend is Just Functions
Then I discovered RPC (Remote Procedure Call), specifically tRPC.
RPC is not new. It has been around since the 1980s. The idea is simple: What if calling a backend function felt exactly like calling a local function?
Traditional RPC frameworks like gRPC require .proto schema files and code generation. tRPC takes a different approach: It uses TypeScript's type system itself as the schema.
No code generation. No schema files. No manual type syncing. Just pure TypeScript inference.
The "Aha!" Moment
With tRPC, I define my backend procedures (functions) in TypeScript. My frontend automatically gets the types. If I change a function signature, my frontend build fails immediately with a TypeScript error—before I even run the app.
This is what I always wanted from TypeScript.
From REST Hell to RPC Heaven: The Migration
Let me show you the difference. Here is my old REST API code:
The Old Way (REST + Manual Types):
1// Backend (Express)
2app.post('/api/users', async (req, res) => {
3 const { name, email } = req.body; // No type safety here
4 const user = await db.users.create({ name, email });
5 res.json(user);
6});
7
8// Frontend types (manually written, manually synced)
9interface CreateUserInput {
10 name: string;
11 email: string;
12}
13
14interface User {
15 id: string;
16 name: string;
17 email: string;
18 createdAt: string;
19}
20
21// Frontend API call
22const createUser = async (input: CreateUserInput): Promise<User> => {
23 const res = await fetch('/api/users', {
24 method: 'POST',
25 headers: { 'Content-Type': 'application/json' },
26 body: JSON.stringify(input),
27 });
28 return res.json(); // Hope this matches the User type!
29};The problems:
- Types are duplicated and manually maintained
- No compile-time guarantee that request/response match
- Every endpoint needs its own fetch wrapper
- Runtime validation is separate from types
The New Way (tRPC):
1// Backend (tRPC Router)
2import { z } from 'zod';
3import { router, publicProcedure } from './trpc';
4
5export const appRouter = router({
6 createUser: publicProcedure
7 .input(z.object({
8 name: z.string().min(1),
9 email: z.string().email(),
10 }))
11 .mutation(async ({ input }) => {
12 // input is automatically typed from Zod schema
13 const user = await db.users.create(input);
14 return user; // Return type is inferred
15 }),
16});
17
18export type AppRouter = typeof appRouter;1// Frontend (Automatic types, zero configuration)
2import { trpc } from './utils/trpc'; // We'll define this once
3
4// Look at this beauty:
5const { mutate } = trpc.createUser.useMutation();
6
7mutate({
8 name: 'John Doe',
9 email: 'john@example.com',
10 // TypeScript autocomplete works here!
11 // If I add a required field in the backend, this breaks at compile time
12});What changed:
- One source of truth: the backend procedure
- Zod handles both validation AND type inference
- Frontend gets full autocomplete and type safety
- Zero manual type definitions
- If the API changes, the frontend build fails immediately
The Setup: Easier Than You Think
Setting up tRPC in a Next.js project takes about 10 minutes. Here is the full setup I use:
Step 1: Install Dependencies
1npm install @trpc/server @trpc/client @trpc/react-query @trpc/next \
2 @tanstack/react-query zodStep 2: Initialize tRPC (Backend)
1// src/server/trpc.ts
2import { initTRPC } from '@trpc/server';
3
4const t = initTRPC.create();
5
6export const router = t.router;
7export const publicProcedure = t.procedure;Step 3: Create Your API (Backend)
1// src/server/routers/appRouter.ts
2import { z } from 'zod';
3import { router, publicProcedure } from '../trpc';
4
5export const appRouter = router({
6 getUser: publicProcedure
7 .input(z.object({ id: z.string() }))
8 .query(async ({ input }) => {
9 return await db.users.findById(input.id);
10 }),
11
12 createUser: publicProcedure
13 .input(z.object({
14 name: z.string(),
15 email: z.string().email(),
16 }))
17 .mutation(async ({ input }) => {
18 return await db.users.create(input);
19 }),
20});
21
22export type AppRouter = typeof appRouter;Step 4: Expose as Next.js API Route
1// src/app/api/trpc/[trpc]/route.ts
2import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
3import { appRouter } from '@/server/routers/appRouter';
4
5const handler = (req: Request) => {
6 return fetchRequestHandler({
7 endpoint: '/api/trpc',
8 req,
9 router: appRouter,
10 createContext: () => ({}),
11 });
12};
13
14export { handler as GET, handler as POST };Step 5: Create Client Hook (Frontend)
1// src/utils/trpc.ts
2import { createTRPCReact } from '@trpc/react-query';
3import { AppRouter } from '@/server/routers/appRouter';
4
5export const trpc = createTRPCReact<AppRouter>();Step 6: Wrap Your App with Provider
1// src/app/providers.tsx
2'use client';
3import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4import { httpBatchLink } from '@trpc/client';
5import { trpc } from '@/utils/trpc';
6import { useState } from 'react';
7
8export function Providers({ children }: { children: React.ReactNode }) {
9 const [queryClient] = useState(() => new QueryClient());
10 const [trpcClient] = useState(() =>
11 trpc.createClient({
12 links: [
13 httpBatchLink({ url: 'http://localhost:3000/api/trpc' }),
14 ],
15 })
16 );
17
18 return (
19 <trpc.Provider client={trpcClient} queryClient={queryClient}>
20 <QueryClientProvider client={queryClient}>
21 {children}
22 </QueryClientProvider>
23 </trpc.Provider>
24 );
25}Step 7: Use It (Frontend Component)
1'use client';
2import { trpc } from '@/utils/trpc';
3
4export default function UserProfile() {
5 const { data, isLoading } = trpc.getUser.useQuery({ id: '123' });
6 const createMutation = trpc.createUser.useMutation();
7
8 if (isLoading) return <div>Loading...</div>;
9
10 return (
11 <div>
12 <h1>{data?.name}</h1>
13 <button onClick={() => {
14 createMutation.mutate({
15 name: 'Jane',
16 email: 'jane@example.com',
17 });
18 }}>
19 Create User
20 </button>
21 </div>
22 );
23}That is it. You now have a fully type-safe API with zero manual type definitions.
The Hidden Features That Changed My Workflow
1. Request Batching (Zero Configuration)
tRPC automatically batches multiple requests made at the same time into a single HTTP call. If your component calls 3 different queries on mount, tRPC sends them as one request.
- Why it matters: Reduces network overhead and improves performance, especially on slow connections.
- Cost: Zero code changes. It just works.
2. React Query Integration (Caching & Refetching)
Because tRPC uses React Query under the hood, you get advanced features for free:
1const { data, isLoading, refetch } = trpc.getUser.useQuery(
2 { id: '123' },
3 {
4 staleTime: 5000, // Cache for 5 seconds
5 refetchOnWindowFocus: true, // Refetch when user returns to tab
6 }
7);3. Middleware for Auth (Context Injection)
You can add authentication middleware that runs before every protected procedure:
1const protectedProcedure = publicProcedure.use(async ({ ctx, next }) => {
2 const user = await getUserFromToken(ctx.req.headers.authorization);
3 if (!user) throw new TRPCError({ code: 'UNAUTHORIZED' });
4
5 return next({
6 ctx: { user }, // Inject user into context
7 });
8});
9
10export const appRouter = router({
11 getProfile: protectedProcedure.query(({ ctx }) => {
12 // ctx.user is typed and guaranteed to exist
13 return { name: ctx.user.name };
14 }),
15});4. Subscriptions (Real-Time Data)
tRPC supports WebSocket subscriptions for real-time features:
1// Backend
2export const appRouter = router({
3 onUserUpdate: publicProcedure.subscription(() => {
4 return observable<User>((emit) => {
5 const interval = setInterval(() => {
6 emit.next({ id: '1', name: 'Updated Name' });
7 }, 1000);
8
9 return () => clearInterval(interval);
10 });
11 }),
12});
13
14// Frontend
15trpc.onUserUpdate.useSubscription(undefined, {
16 onData: (user) => console.log('User updated:', user),
17});5. Monorepo Support (Share Types Across Projects)
Because tRPC types are just TypeScript, you can share your AppRouter type across multiple frontend projects (web, mobile, desktop):
1my-monorepo/
2├── packages/
3│ ├── api/ (tRPC backend)
4│ ├── web/ (Next.js, imports AppRouter)
5│ ├── mobile/ (React Native, imports AppRouter)
6│ └── shared/ (Zod schemas, shared utilities)The Performance Story: tRPC vs REST vs GraphQL
Here is why I chose tRPC over other solutions:
- vs REST: No manual type syncing. Request batching. Smaller payload (no redundant headers for multiple requests).
- vs GraphQL: No schema files. No code generation. No learning curve. Smaller runtime (GraphQL libraries are heavy).
- vs gRPC: No Protocol Buffers. No
.protofiles. Works over HTTP (no special infrastructure needed).
For TypeScript full-stack apps, tRPC hits the perfect balance of simplicity and power.
Conclusion: Type Safety Without the Friction
I spent 6 months fighting my tools. I knew TypeScript could prevent bugs, but I was manually bridging the gap between frontend and backend. I was doing the compiler's job.
tRPC gave me what I always wanted: end-to-end type safety with zero configuration.
Now, when I change an API function, my frontend build fails immediately. No more runtime surprises. No more "undefined is not an object" errors in production. No more copying types between files.
If you are building a TypeScript full-stack app and you are still using REST with manual type definitions, you are working too hard. RPC is not just a buzzword from the 1980s. With tRPC, it is the most productive way to build type-safe APIs in 2025.