All posts
22 Aug 2026

End-to-End Type Safety Without Code Generation: Building Type-Safe APIs with tRPC and Node.js

Discover how to achieve seamless end-to-end type safety between your Node.js backend and TypeScript frontend using tRPC and Zod, completely eliminating the need for manual code generation.

End-to-End Type Safety Without Code Generation: Building Type-Safe APIs with tRPC and Node.js

For years, building a full-stack TypeScript application meant accepting a painful developer experience gap at the network boundary. Whether you used REST with manual interface duplication or GraphQL with heavy code-generation pipelines (like graphql-codegen), keeping your frontend and backend types in sync felt like a constant tax on productivity.

Change the backend schema? Update the types. Run the code generator. Fix the frontend compile errors. Repeat.

Enter tRPC.

tRPC allows you to build strongly typed APIs over HTTP without schemas, code generation, or cumbersome compilation steps. By leveraging TypeScript’s advanced type inference, your frontend instantly knows the exact input requirements and return types of your backend procedures simply by importing your backend’s router type definitions.

In this practical, code-heavy guide, we will replace a traditional API setup with a modern tRPC backend in Node.js, integrate robust input validation using Zod, add custom middleware, and consume the API seamlessly on the frontend.


The Architecture of tRPC

Traditional REST APIs decouple the client and server through documentation (like OpenAPI/Swagger) or shared types. GraphQL introduces a dedicated schema language (.graphql files) that requires generation tools to map back to TypeScript.

tRPC takes a radically different approach: direct inference. Because both your Node.js server and your frontend application are written in TypeScript, tRPC infers the input and output types of your API procedures directly from your server implementation. The client doesn’t need to run a generation script; it references the exported TypeScript type of your server router.

code
+-----------------------+
|  Node.js tRPC Router  | <--- (Defines procedures, Zod validation)
+-----------------------+
            |
            | (Infers types via TypeScript compiler)
            v
+-----------------------+
|     Frontend App      | <--- (Consumes fully typed client methods)
+-----------------------+

—+

Step 1: Setting Up the Node.js Backend

Let’s start by setting up a foundational Node.js project with Express and tRPC. First, initialize your project and install the necessary dependencies:

npm init -y
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod express cors
npm install -D typescript @types/node @types/express @types/cors tsx

Next, ensure your tsconfig.json is properly configured for strict type checking:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Initializing tRPC and Context

The tRPC context is an object available to all of your API procedures. It typically holds request-specific data such as authenticated user sessions, database connections, or request headers.

Create a file named src/trpc.ts:

import { initTRPC, TRPCError } from '@trpc/server';
import * as trpcExpress from '@trpc/server/adapters/express';

// Define the context interface
export const createContext = ({
  req,
  res,
}:
  | trpcExpress.CreateExpressContextOptions
  | { req: any; res: any }) => {
  // Extract a mock authorization token for demonstration
  const token = req.headers.authorization?.split(' ')[1];
  
  return {
    userId: token === 'secret-token' ? 'user_123' : null,
  };
};

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

// Initialize tRPC with our context type
const t = initTRPC.context<Context>().create();

export const router = t.router;
export const publicProcedure = t.procedure;

// Protected procedure middleware example
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.userId) {
    throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
  }
  return next({
    ctx: {
      userId: ctx.userId, // TypeScript now knows userId is strictly a string, not null
    },
  });
});

export const protectedProcedure = t.procedure.use(isAuthed);

—+

Step 2: Building Routers and Validating Inputs with Zod

tRPC integrates natively with Zod, allowing you to validate runtime inputs while automatically inferring their TypeScript types for compile-time safety.

Let’s build a sample Todo application router in src/router.ts:

import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from './trpc';

// In-memory database mock
interface Todo {
  id: string;
  title: string;
  completed: boolean;
  userId: string;
}

const todos: Todo[] = [
  { id: '1', title: 'Learn tRPC', completed: false, userId: 'user_123' },
];

export const appRouter = router({
  // Public query procedure
  getPublicStatus: publicProcedure.query(() => {
    return { status: 'API is running successfully', timestamp: new Date() };
  }),

  // Protected query procedure
  getTodos: protectedProcedure.query(({ ctx }) => {
    return todos.filter((todo) => todo.userId === ctx.userId);
  }),

  // Protected mutation procedure with Zod input validation
  createTodo: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1, 'Title cannot be empty').max(100),
      })
    )
    .mutation(({ input, ctx }) => {
      const newTodo: Todo = {
        id: Math.random().toString(36.substring(2, 9)),
        title: input.title,
        completed: false,
        userId: ctx.userId,
      };

      todos.push(newTodo);
      return newTodo;
    }),

  // Mutation with parameter validation
  toggleTodo: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      const todo = todos.find((t) => t.id === input.id);
      if (!todo) throw new Error('Todo not found');
      
      todo.completed = !todo.completed;
      return todo;
    }),
});

// Export type definition of API
export type AppRouter = typeof appRouter;

Notice the final line: export type AppRouter = typeof appRouter;. This is the secret sauce. By exporting only the type of your router, you provide your frontend client with all the signature information it needs without bundling any backend execution logic.

—+

Step 3: Exposing the API via Express

Now, hook up your tRPC router to an Express server in src/server.ts:

import express from 'express';
import cors from 'cors';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './router';
import { createContext } from './trpc';

const app = express();

app.use(cors({ origin: 'http://localhost:3000' }));
app.use(express.json());

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

const PORT = 4000;
app.listen(PORT, () => {
  console.log(`tRPC server running at http://localhost:${PORT}/trpc`);
});

Start your development server using tsx:

npx tsx src/server.ts

—+

Step 4: Consuming the tRPC API on the Frontend

Now, let’s switch over to your frontend client (React + TanStack React Query is the standard pairing for tRPC).

Configuring the tRPC Client

Create a utility file to instantiate your typed tRPC React client (src/utils/trpc.ts in your frontend codebase):

import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../../../backend/src/router'; // Import the type from backend

export const trpc = createTRPCReact<AppRouter>();

Set up your React Query providers in your application entry point:

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

function App() {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:4000/trpc',
          headers() {
            return {
              authorization: 'Bearer secret-token', // Simulating auth
            };
          },
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <TodoContainer />
      </QueryClientProvider>
    </trpc.Provider>
  );
}

Using Typed Procedures in Components

Inside your components, queries and mutations are fully type-safe. Autocomplete will show you available procedures, Zod validation requirements, and return object shapes instantly.

import React, { useState } from 'react';
import { trpc } from './utils/trpc';

export function TodoContainer() {
  const [title, setTitle] = useState('');
  
  const utils = trpc.useContext();
  
  // Fully typed query
  const { data: todos, isLoading } = trpc.getTodos.useQuery();
  
  // Fully typed mutation with cache invalidation
  const addTodo = trpc.createTodo.useMutation({
    onSuccess: () => {
      setTitle('');
      utils.getTodos.invalidate(); // Refetch todos automatically
    },
  });

  const toggleTodo = trpc.toggleTodo.useMutation({
    onSuccess: () => {
      utils.getTodos.invalidate();
    },
  });

  if (isLoading) return <div>Loading todos...</div>;

  return (
    <div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
      <h1>My Tasks (tRPC Powered)</h1>
      
      <div style={{ marginBottom: '1rem' }}>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="New task..."
        />
        <button
          onClick={() => addTodo.mutate({ title })}
          disabled={addTodo.isLoading}
        >
          Add Todo
        </button>
      </div>

      <ul>
        {todos?.map((todo) => (
          <li key={todo.id} style={{ marginBottom: '0.5rem' }}>
            <span
              style={{
                textDecoration: todo.completed ? 'line-through' : 'none',
                cursor: 'pointer',
              }}
              onClick={() => toggleTodo.mutate({ id: todo.id })}
            >
              {todo.title}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

—+

Why This Workflow Changes Everything

By adopting tRPC over traditional REST or GraphQL pipelines, you unlock several profound developer experience benefits:

  1. Zero Code Generation Scripts: You never have to worry about running npm run codegen or watching files. The TypeScript compiler handles type propagation natively.
  2. Refactoring Confidence: If you rename a database column or alter a Zod validation schema on the backend, your frontend components will immediately flag compile errors before you even save the file.
  3. DRY Validation with Zod: Input validation logic written with Zod serves a dual purpose: runtime safety on the backend and precise input inference on the client.
  4. IntelliSense Out of the Box: Writing API requests feels like calling native JavaScript functions rather than constructing raw HTTP request payloads.

Conclusion

tRPC bridges the gap between backend and frontend development in full-stack TypeScript applications. By cutting out code generation tools, manual interface mapping, and runtime contract mismatches, tRPC lets you write cleaner, faster, and dramatically safer code.

If your next project is entirely TypeScript-based from database to UI, replacing your REST controllers with tRPC routers will instantly eliminate an entire class of runtime bugs and significantly accelerate your team’s velocity.

More posts