0Pricing

Unlock Full-Stack Type Safety: Your First Steps with tRPC

Discover tRPC, the revolutionary framework that brings end-to-end type safety to your full-stack applications. This introductory guide will walk you through setting up your first tRPC project, from server to client, ensuring a seamless and type-safe development experience.

T
tRPC End-to-End Type Safe APIs · 11 min read · 2,112 words

Welcome, future full-stack wizards, to the first installment of our deep dive into tRPC – the game-changer for building end-to-end type-safe APIs! At CoddyKit, we're all about empowering you with the tools and knowledge to build robust, efficient, and enjoyable applications. And when it comes to API development in the TypeScript ecosystem, tRPC stands out as a true paradigm shift.

If you've ever found yourself wrestling with API clients, manually syncing types between your backend and frontend, or dealing with the overhead of code generation, then you're in for a treat. tRPC promises to eliminate these headaches, bringing a delightful developer experience and unparalleled type safety from your server all the way to your client. Let's embark on this journey and get you started with tRPC!

What is tRPC and Why Should You Care?

At its core, tRPC (TypeScript Remote Procedure Call) is a framework that allows you to build fully type-safe APIs without the need for schema definitions, code generation, or runtime validation libraries (though it integrates beautifully with them). Instead, tRPC leverages TypeScript's powerful inference capabilities to share types directly between your backend and frontend.

Think about your typical full-stack TypeScript project. You might have a backend written in Node.js with Express and a frontend in React. When you define an API endpoint on the server, say /api/users, and it returns an array of User objects, you then need to manually define that User type on your frontend. If the backend changes the shape of User, your frontend won't know until runtime, potentially leading to bugs or requiring manual updates to client-side types or OpenAPI/GraphQL schemas.

This is where tRPC shines. It completely removes that disconnect. With tRPC, you define your API once on the server, and because your client application can literally import the types directly from your server-side router, TypeScript guarantees that your client-side calls match your server's expectations. This means:

  • End-to-End Type Safety: Catch errors at compile-time, not runtime.
  • Zero Boilerplate: No more manual type syncing, schema definitions, or code generation.
  • Exceptional Developer Experience: Autocomplete for API calls, inputs, and outputs right in your IDE.
  • Faster Development: Spend less time debugging type mismatches and more time building features.
  • Smaller Bundle Sizes: Only the types you need are imported, no heavy client SDKs.

The Magic Behind tRPC: How it Works

tRPC isn't magic, but it certainly feels like it. It builds upon the concept of Remote Procedure Calls (RPC), where a client executes a function on a remote server as if it were a local function. What makes tRPC unique is how it integrates TypeScript into this concept.

When you define your API routes and procedures on the server using tRPC, you're essentially creating a TypeScript object that describes your API. This object, known as your "App Router," contains all the information about your procedures, their inputs, and their outputs. Because this router is a TypeScript object, its types can be inferred and then exported.

On the client side, instead of fetching data via traditional HTTP endpoints and then manually typing the responses, you import the type definition of your App Router from the server. tRPC then uses this imported type to create a fully type-safe client. When you call a procedure on the client, TypeScript checks that your arguments match the server's expected input types and infers the return type of the procedure, all before your code even runs!

This direct sharing of types is fundamental. It means your frontend code is always in perfect sync with your backend, providing an incredibly robust and efficient development workflow.

Setting Up Your First tRPC Project: A Step-by-Step Guide

Let's roll up our sleeves and get our hands dirty by setting up a minimal tRPC project. We'll create a simple server and a React client to demonstrate the end-to-end type safety.

Prerequisites

Before we begin, ensure you have the following installed:

  • Node.js (LTS version recommended)
  • npm, yarn, or pnpm package manager
  • TypeScript (will be installed as a dev dependency)

Project Initialization

First, let's create a new directory for our project and initialize it:

mkdir trpc-starter
cd trpc-starter
npm init -y
npm install --save-dev typescript ts-node @types/node
npx tsc --init

This sets up a basic TypeScript project. Your tsconfig.json might need minor adjustments, but the default should be sufficient for now. Make sure "outDir" is set to something like "./dist" and "rootDir" to "./src", and "esModuleInterop": true.

Server Setup (Backend)

Now, let's build our tRPC server. We'll use express as our HTTP server, but tRPC is framework-agnostic and can work with others like Fastify or even bare Node.js http.

Install the necessary server dependencies:

npm install @trpc/server @trpc/express zod
npm install --save-dev @types/express

We'll use zod for input validation, which is a common and highly recommended practice with tRPC.

1. Create a tRPC Context (src/server/createContext.ts)

The context is an object that is created once per request and passed down to all your tRPC procedures. It's a great place to put things like database connections, authentication details, or user session data.

// src/server/createContext.ts
import * as trpc from '@trpc/server';
import * as trpcExpress from '@trpc/express';

// This is the input type for createContext
export function createContext({ req, res }: trpcExpress.CreateExpressContextOptions) {
  // For this example, our context is simple. In a real app, you'd add things like auth here.
  return { /* userId: req.headers['x-user-id'] */ };
}

export type Context = trpc.inferAsyncReturnType<typeof createContext>;

2. Initialize tRPC and Define Procedures (src/server/router/_app.ts and src/server/router/example.ts)

We'll create a root router and a sub-router for our procedures.

// src/server/router/example.ts
import { z } from 'zod';
import { publicProcedure, router } from '../trpc'; // We'll create trpc.ts next

export const exampleRouter = router({
  hello: publicProcedure
    .input(z.object({ name: z.string().nullish() }))
    .query(({ input }) => {
      return {
        greeting: `Hello ${input?.name ?? 'world'}!`,
      };
    }),
  // Add more procedures here later!
});
// src/server/trpc.ts - tRPC instance initialization
import { initTRPC } from '@trpc/server';
import { Context } from './createContext';

/**
 * Initialization of tRPC backend
 * Should be done only once per backend!
 */
const t = initTRPC.context<Context>().create();

/**
 * Export reusable router and procedure helpers
 * that can be used throughout the router
 */
export const router = t.router;
export const publicProcedure = t.procedure;
// src/server/router/_app.ts - The Root App Router
import { router } from '../trpc';
import { exampleRouter } from './example';

export const appRouter = router({
  example: exampleRouter,
  // Add more sub-routers here for different domains (e.g., auth, posts)
});

// Export only the type definition of the app router
// This is important for the client to import!
export type AppRouter = typeof appRouter;

3. Create the Express Server (src/server/index.ts)

Finally, let's set up an Express server to expose our tRPC router.

// src/server/index.ts
import express from 'express';
import cors from 'cors';
import * as trpcExpress from '@trpc/express';
import { appRouter } from './router/_app';
import { createContext } from './createContext';

const app = express();
const port = 3000;

app.use(cors()); // Enable CORS for client communication

app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext,
  }),
);

app.get('/', (req, res) => {
  res.send('Hello from tRPC server!');
});

app.listen(port, () => {
  console.log(`tRPC server listening at http://localhost:${port}`);
});

To run your server, add a script to package.json:

"scripts": {
  "start-server": "ts-node src/server/index.ts"
}

Then run: npm run start-server

Client Setup (Frontend - React Example)

Now for the exciting part: consuming our type-safe API from the frontend. We'll use React and @tanstack/react-query, which tRPC integrates seamlessly with for data fetching, caching, and more.

First, initialize a React project inside our existing trpc-starter directory. For simplicity, we'll just add React directly, but in a real app, you'd likely use a framework like Next.js or Vite.

npm install react react-dom
npm install @trpc/client @tanstack/react-query @trpc/react-query
npm install --save-dev @types/react @types/react-dom

1. Create the tRPC Client Instance (src/client/utils/trpc.ts)

This file is where we'll set up our tRPC client and integrate it with React Query. Crucially, this is where we import the AppRouter type from our server.

// src/client/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../../server/router/_app'; // <--- The magic happens here!

export const trpc = createTRPCReact<AppRouter>();

Notice that import type { AppRouter } from '../../server/router/_app'; line. That's the core of tRPC's end-to-end type safety! Your client now knows the exact shape of your server's API.

2. Create the React App (src/client/App.tsx)

Now, let's create a simple React component that uses our tRPC client to fetch data.

// src/client/App.tsx
import React, { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc } from './utils/trpc';
import { httpBatchLink } from '@trpc/client';

const queryClient = new QueryClient();

const TrpcProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/trpc',
        }),
      ],
    }),
  );
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
};

function App() {
  const [name, setName] = useState('');
  const { data, isLoading, error } = trpc.example.hello.useQuery({ name });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div style={{ color: 'red' }}>Error: {error.message}</div>;

  return (
    <div>
      <h1>tRPC Hello World</h1>
      <p>{data?.greeting}</p>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Enter your name"
      />
      <p>Try changing the input above and see the greeting update! Notice the autocomplete for <code>name</code> and the type safety of <code>data.greeting</code>.</p>
    </div>
  );
}

export default function Root() {
  return (
    <TrpcProvider>
      <App />
    </TrpcProvider>
  );
}

To run your client, you'll need an HTML file and a build step. For simplicity, let's assume you have a basic index.html and use a simple bundler (like Parcel or Webpack) or just ts-node for demonstration, though a proper React setup is recommended.

Add a script to package.json for the client:

"scripts": {
  "start-client": "parcel src/client/index.html" // If using parcel
}

Or for a quick demo, create a basic index.ts in src/client and modify your package.json to run it:

// src/client/index.ts
import React from 'react';
import ReactDOM from 'react-dom/client';
import Root from './App';

const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
const root = ReactDOM.createRoot(rootElement);
root.render(
  <React.StrictMode>
    <Root />
  </React.StrictMode>,
);

And a corresponding index.html in src/client:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>tRPC Starter Client</title>
</head>
<body>
    <div id="root"></div>
    <script type="module" src="index.ts"></script>
</body>
</html>

Then, you'd need a bundler like Parcel for index.ts to run in the browser. For a truly minimal setup without a bundler, you could compile the client-side TypeScript to JavaScript and serve it, but that adds complexity. For this guide, just understand the client code and its type-safe interaction.

Running the Application

1. Open two terminal windows.

2. In the first terminal, start the server:

npm run start-server

You should see: tRPC server listening at http://localhost:3000

3. In the second terminal, you would typically start your client-side development server (e.g., npm start for a create-react-app or npm run dev for Next.js/Vite). If you set up Parcel, you'd run:

npm install -g parcel # if you don't have it
npm run start-client

Navigate to http://localhost:1234 (or whatever port your client uses). You should see "Hello world!" and an input field. Type your name, and watch the greeting update instantly! More importantly, try to access data.nonExistentProperty in your React component, and TypeScript will immediately flag it as an error, demonstrating the end-to-end type safety in action.

What's Next?

Congratulations! You've successfully set up your first tRPC project and experienced the power of end-to-end type safety. This is just the beginning of what tRPC can do.

In the upcoming posts of this series, we'll delve deeper into various aspects of tRPC:

  • Post 2: Best Practices and Tips – Learn how to structure your tRPC applications for scalability and maintainability.
  • Post 3: Common Mistakes and How to Avoid Them – Understand potential pitfalls and how to navigate them.
  • Post 4: Advanced Techniques or Real-World Use Cases – Explore authentication, mutations, subscriptions, and more complex scenarios.
  • Post 5: Future Trends and Ecosystem Overview – Look at the broader landscape, tRPC's roadmap, and integrations.

Conclusion

tRPC is not just another API framework; it's a fundamental shift in how we approach full-stack development with TypeScript. By eliminating the need for manual type synchronization and code generation, it streamlines the development process, reduces bugs, and significantly enhances the developer experience. The ability to import server types directly into your client is a game-changer, providing an unparalleled level of confidence and speed.

We hope this introductory guide has ignited your interest in tRPC. Experiment with the code, tweak the procedures, and feel the power of true end-to-end type safety. Stay tuned for our next post, where we'll explore best practices to make your tRPC journey even smoother!

ProgrammingTutorialCoddyKit

Enjoyed this article?

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

Browse All Articles →