Back to Scaling Node.js Applications guides

tRPC vs GraphQL: Choosing the Right Tool for Your TypeScript APIs

Stanley Ulili
Updated on May 12, 2025

tRPC and GraphQL are two tools that help you build APIs in modern web apps. They both solve the problem of connecting your frontend to backend logic, but they approach it differently.

tRPC focuses on end-to-end type safety and seamless TypeScript integration. GraphQL offers a flexible query language that lets clients ask for exactly the data they need.

In this guide, you'll see how they compare in real-world usage—setup, configuration, development experience, and where each one makes the most sense to help you decide which one fits your project better.

What is tRPC?

Screenshot of tRPC Github page

tRPC stands for TypeScript Remote Procedure Call. It's a lightweight framework made specifically for TypeScript apps that gives you end-to-end type safety between your client and server. Developers created tRPC to simplify API development by removing the need for schema languages or code generators.

tRPC takes a code-first approach and uses TypeScript as the foundation for both defining and using APIs. It focuses on making development smooth by using TypeScript's type system to create a direct connection between your backend and frontend code.

What is GraphQL?

Screenshot of GraphQL website

GraphQL is a query language and runtime for APIs that Facebook developed in 2015. Unlike traditional REST APIs, GraphQL lets clients ask for exactly the data they need in a single request, which solves common problems with fetching too much or too little data.

GraphQL uses a strongly-typed schema to define available data types and their relationships. This schema works as a contract between client and server, making it clear what data you can request and how it's structured. GraphQL also includes built-in tools for exploring the API, which makes it easy to understand what's available.

tRPC vs GraphQL: a quick comparison

Your choice between tRPC and GraphQL affects both how efficiently you develop and how well your application performs. Each has its own philosophy and works better in different situations.

Here's a comparison of their key differences:

Feature tRPC GraphQL
Main approach TypeScript-first RPC framework Query language with schema definition
Type safety End-to-end TypeScript types Schema-based types with code generation for TypeScript
Learning curve Easy for TypeScript developers Steeper, requires learning schema language
Making queries Function calls with TypeScript typing Declarative query language with field selection
Client usage Direct function calls with autocompletion Query strings or builder libraries
Schema definition Implicit through TypeScript types Explicit schema using GraphQL language
Ecosystem Growing, focused on TypeScript Mature, works with many languages
Community Smaller but growing quickly Large and well-established
Flexibility Limited to TypeScript Works across multiple languages
Public API usage Not designed for public APIs Great for public APIs with self-documentation

Installation and configuration

The way you set up these technologies gives you a first taste of how differently they work. Let's look at how you install and configure each one.

When you set up tRPC, you'll notice it focuses on simplicity and TypeScript integration. You'll install the packages and create a few files to set up your API structure.

First, install the packages:

 
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod

Next, create your tRPC backend in a file like server/trpc.ts:

 
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

// Create a tRPC instance
export const t = initTRPC.create();

// Define procedures (like GraphQL resolvers)
export const appRouter = t.router({
  hello: t.procedure
    .input(z.object({ name: z.string().optional() }))
    .query(({ input }) => {
      return {
        greeting: `Hello ${input.name ?? 'world'}!`,
      };
    }),
});

// Export type router type - this powers your client
export type AppRouter = typeof appRouter;

Finally, set up the client (for React):

 
import { createTRPCReact } from '@trpc/react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server/trpc';

// Create React hooks for tRPC
export const trpc = createTRPCReact<AppRouter>();

// Create React provider component
function MyApp({ children }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() => 
    trpc.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/api/trpc',
        }),
      ],
    })
  );

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

That's it! You can now use your type-safe API right away.

Setting up GraphQL takes more work than tRPC. You need to define a schema, create resolvers, and set up a server.

Start by installing the packages:

 
npm install graphql apollo-server @apollo/client

Then, define your schema using GraphQL's Schema Definition Language:

 
// schema.ts
import { gql } from 'apollo-server';

export const typeDefs = gql`
  type Query {
    hello(name: String): Greeting
  }

  type Greeting {
    greeting: String!
  }
`;

Next, create resolvers that implement the schema:

 
// resolvers.ts
export const resolvers = {
  Query: {
    hello: (_, { name }) => {
      return {
        greeting: `Hello ${name ?? 'world'}!`,
      };
    },
  },
};

Set up the Apollo Server:

 
// server.ts
import { ApolloServer } from 'apollo-server';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

server.listen().then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});

Finally, set up the client:

 
// client.tsx
import { ApolloClient, InMemoryCache, ApolloProvider, gql } from '@apollo/client';

const client = new ApolloClient({
  uri: 'http://localhost:4000',
  cache: new InMemoryCache(),
});

function App({ children }) {
  return (
    <ApolloProvider client={client}>
      {children}
    </ApolloProvider>
  );
}

// Define query for client
const HELLO_QUERY = gql`
  query GetGreeting($name: String) {
    hello(name: $name) {
      greeting
    }
  }
`;

As you can see, GraphQL needs more boilerplate and separate files for schema and resolvers, compared to tRPC's simpler approach.

Query building

tRPC and GraphQL handle queries in totally different ways, which reveals their different philosophies.

When you use tRPC to query your API, it feels just like calling local functions. You get full TypeScript autocompletion and type checking:

 
// Client-side query with tRPC
function GreetingComponent() {
  const name = "Alice";
  // Complete type safety and autocompletion
  const greeting = trpc.hello.useQuery({ name });

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

  return <div>{greeting.data.greeting}</div>;
}

The great thing about tRPC is that your editor shows you available procedures and what inputs they need. If you change a procedure on the server, TypeScript immediately flags any client code that doesn't match.

GraphQL uses a declarative query language where clients specify exactly what fields they want:

 
// Client-side query with GraphQL
function GreetingComponent() {
  const name = "Alice";
  const { loading, data } = useQuery(HELLO_QUERY, {
    variables: { name },
  });

  if (loading) return <div>Loading...</div>;

  return <div>{data.hello.greeting}</div>;
}

With GraphQL, you manually define your queries, but you get precise control over what data comes back. You can pick only certain fields from complex objects, which really helps when you need to save bandwidth.

Type safety

One of the biggest differences between these technologies is how they handle type safety.

tRPC gives you seamless end-to-end type safety without generating any code. When you define a procedure on the server, the TypeScript types automatically work on the client:

 
// Server-side procedure
const userRouter = t.router({
  getUser: t.procedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const user = await db.users.findUnique({ where: { id: input.id } });
      return user; // TypeScript knows what this data looks like
    }),
});

// Client-side usage
const user = trpc.user.getUser.useQuery({ id: "123" });
console.log(user.data.email); // TypeScript knows this exists

If you change the return type on the server, the client type changes automatically. This tight connection ensures your frontend code always matches your backend.

GraphQL has its own type system in the schema, but to work with TypeScript, you usually need to generate code:

 
// GraphQL schema
const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
  }

  type Query {
    getUser(id: ID!): User
  }
`;

// Generated TypeScript (usually from a tool like GraphQL Codegen)
// types.ts (generated)
export interface User {
  id: string;
  name: string;
  email: string;
}

export interface GetUserQueryVariables {
  id: string;
}

export interface GetUserQuery {
  getUser: User | null;
}

With GraphQL, you need to run the code generator whenever your schema changes to keep TypeScript types updated. This extra step adds complexity but allows GraphQL to work with many languages besides TypeScript.

Performance and scalability

tRPC is fast out of the box. It avoids parsing query strings and directly maps incoming requests to TypeScript functions. This works well in projects where the front end and back end live in the same codebase.

It supports request batching, so multiple API calls can be combined into a single HTTP request to reduce network overhead. The tradeoff is that tRPC doesn't include caching or built-in support for handling large, external-facing APIs. For those features, you'll usually rely on something like React Query.

 
// tRPC batching setup for better performance
const trpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: '/api/trpc',
    }),
  ],
});

GraphQL approaches performance differently. Since clients choose exactly what data to request, you can avoid over-fetching and reduce payload size.

But that flexibility adds complexity. The server must parse and validate every query, and deeply nested queries can lead to inefficient database access. Tools like DataLoader help solve this by batching related lookups.

GraphQL clients like Apollo also support advanced caching and background updates. If you're building a public API, you may also need to limit query depth or complexity to avoid performance issues.

 
// GraphQL: using DataLoader to batch and cache database queries
import DataLoader from 'dataloader';
import { getUsersByIds } from './db';

const userLoader = new DataLoader(async (ids: readonly string[]) => {
  const users = await getUsersByIds(ids as string[]);
  return ids.map(id => users.find(user => user.id === id));
});

const resolvers = {
  Post: {
    author: (post, _, { loaders }) => loaders.userLoader.load(post.authorId),
  },
};

Final thoughts

tRPC and GraphQL solve the same problem in different ways. tRPC is best for full-stack TypeScript apps where simplicity and type safety matter most. It’s fast, easy to set up, and ideal when the frontend and backend are tightly connected.

GraphQL is a better fit when you need flexibility, multiple client support, or a public API. It requires more setup but gives you fine-grained control over data and powerful tooling.

Pick tRPC for speed and tight integration. Choose GraphQL when your API needs to serve a variety of clients with different needs.

Author's avatar
Article by
Stanley Ulili
Stanley Ulili is a technical educator at Better Stack based in Malawi. He specializes in backend development and has freelanced for platforms like DigitalOcean, LogRocket, and AppSignal. Stanley is passionate about making complex topics accessible to developers.
Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github