GraphQL vs. tRPC in Node.js - Choosing Your API Paradigm
Wenhao Wang
Dev Intern · Leapcell

Introduction
Building robust and scalable APIs is a cornerstone of modern web development. In the vast ecosystem of Node.js, developers are presented with a multitude of choices for crafting the interface between their backend and frontend applications. Two powerful contenders that have gained significant traction are GraphQL, often implemented with Apollo Server, and tRPC. Both offer distinct approaches to API development, addressing common challenges like data fetching, type safety, and developer experience, but they do so through fundamentally different philosophies. Understanding the nuances of each is crucial for making an informed decision that aligns with your project's specific needs and future growth. This article delves into these two paradigms, exploring their mechanisms, showcasing their practical application with code examples, and ultimately guiding you toward selecting the most suitable option for your Node.js backend.
Core Concepts
Before we dive into the comparison, let's briefly define the core concepts that underpin our discussion.
GraphQL: A query language for your API, and a runtime for fulfilling those queries with your existing data. It provides a more efficient, powerful, and flexible alternative to REST. Clients can request exactly the data they need, eliminating over-fetching or under-fetching.
Apollo Server: A popular, open-source GraphQL server that helps you connect a GraphQL schema to your data sources. It provides a wide range of features, including schema stitching, caching, and integrations with various Node.js frameworks.
tRPC: Stands for "TypeScript Remote Procedure Call." It's a framework that allows you to confidently build end-to-end type-safe APIs without schemas or code generation. It leverages TypeScript's powerful inference capabilities to achieve type safety directly from your server-side definitions to your client-side calls.
GraphQL with Apollo Server
GraphQL, at its heart, is about defining a schema that describes all possible data that clients can query. This schema acts as a contract between the client and the server.
Principles and Implementation
The core principle of GraphQL is allowing the client to specify exactly what data it needs. This is achieved through a single endpoint that clients interact with, sending queries.
Here’s a basic example of setting up a GraphQL API with Apollo Server:
First, define your GraphQL schema. This schema uses the GraphQL Schema Definition Language (SDL) to specify types, queries, and mutations.
// src/schema.ts import { gql } from 'apollo-server'; export const typeDefs = gql` type Book { id: ID! title: String! author: String! } type Query { books: [Book!]! book(id: ID!): Book } type Mutation { addBook(title: String!, author: String!): Book! } `;
Next, implement resolvers, which are functions that fetch the data for each field in your schema.
// src/resolvers.ts const books = [ { id: '1', title: 'The Great Gatsby', author: 'F. Scott Fitzgerald' }, { id: '2', title: 'To Kill a Mockingbird', author: 'Harper Lee' }, ]; export const resolvers = { Query: { books: () => books, book: (parent: any, { id }: { id: string }) => books.find(book => book.id === id), }, Mutation: { addBook: (parent: any, { title, author }: { title: string, author: string }) => { const newBook = { id: String(books.length + 1), title, author }; books.push(newBook); return newBook; }, }, };
Finally, set up and start the Apollo Server.
// src/index.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}`); });
Clients can then send queries like this:
query GetBooks { books { id title } }
Or mutations:
mutation AddNewBook { addBook(title: "1984", author: "George Orwell") { id title author } }
Application Scenarios for GraphQL
GraphQL excels in situations where:
- Complex Data Structures: Your data model is intricate, with many interconnected resources.
- Multiple Clients: You need to support various clients (web, mobile, IoT) with different data requirements, and you want to avoid versioning multiple REST endpoints.
- Preventing Over/Under-fetching: You want precise control over the data received by the client, minimizing unnecessary data transfer.
- Rapid Iteration: You need to quickly evolve your API without breaking existing clients, as new fields can be added to types without impacting old queries.
tRPC
tRPC takes a radically different approach. Instead of defining a separate schema, it leverages TypeScript to infer the API contract directly from your backend code. This means your API types are automatically derived from your server-side functions.
Principles and Implementation
The core principle of tRPC is end-to-end type safety without an intermediate schema layer or code generation. It achieves this by defining your API routes as TypeScript functions on the server and then using a client-side library to call these functions in a type-safe manner.
Here’s a basic example of setting up a tRPC API:
First, define your tRPC router and procedures on the server.
// src/server.ts import { initTRPC } from '@trpc/server'; import { z } from 'zod'; // For input validation const t = initTRPC.create(); // Initialize tRPC const books = [ { id: '1', title: 'The Great Gatsby', author: 'F. Scott Fitzgerald' }, { id: '2', title: 'To Kill a Mockingbird', author: 'Harper Lee' }, ]; export const appRouter = t.router({ book: t.router({ list: t.procedure.query(() => { return books; }), byId: t.procedure .input(z.object({ id: z.string() })) .query(({ input }) => { return books.find(book => book.id === input.id); }), add: t.procedure .input(z.object({ title: z.string(), author: z.string() })) .mutation(({ input }) => { const newBook = { id: String(books.length + 1), ...input }; books.push(newBook); return newBook; }), }), }); export type AppRouter = typeof appRouter; // Export the type of your router
Then, set up an HTTP adapter to expose your tRPC router. For Node.js, you might use @trpc/server/adapters/fastify
or @trpc/server/adapters/standalone
.
// src/index.ts (using @trpc/server/adapters/standalone for simplicity) import { createHTTPServer } from '@trpc/server/adapters/standalone'; import { appRouter } from './server'; const server = createHTTPServer({ router: appRouter, // Optional: createContext ensures `ctx` is available in all procedures createContext() { return {}; }, }); server.listen(3000, () => { console.log('🚀 tRPC server listening on http://localhost:3000'); });
On the client side, you can create a tRPC client and call your procedures with full type safety.
// src/client.ts (e.g., in a React component) import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from './server'; // Import the server-side type const trpcClient = createTRPCProxyClient<AppRouter>({ links: [ httpBatchLink({ url: 'http://localhost:3000/trpc', // Your tRPC endpoint }), ], }); async function fetchBooks() { const allBooks = await trpcClient.book.list.query(); console.log('All books:', allBooks); const book = await trpcClient.book.byId.query({ id: '1' }); console.log('Book by ID:', book); const newBook = await trpcClient.book.add.mutate({ title: 'Dune', author: 'Frank Herbert' }); console.log('Added book:', newBook); } fetchBooks();
Notice how trpcClient.book.list.query()
and trpcClient.book.byId.query()
are automatically type-safe, and the input
argument for byId
is strictly enforced.
Application Scenarios for tRPC
tRPC shines in contexts where:
- Full-Stack TypeScript: You are building a full-stack application using TypeScript on both the frontend and backend.
- Developer Experience is Paramount: You prioritize an unparalleled developer experience with instant type safety and auto-completion.
- Reduced Boilerplate: You want to minimize boilerplate code for API definitions and client-side consumption.
- Internal Monorepos: It's particularly effective in monorepo setups where the frontend and backend share types effortlessly.
- REST-like Simplicity, Type-Safe Advantage: You prefer a simpler, more direct RPC-style interaction but require robust type checking.
Conclusion
Both GraphQL with Apollo Server and tRPC offer compelling solutions for building Node.js APIs, but they cater to different philosophies and use cases. GraphQL provides a powerful, flexible query language and schema-driven contract that excels in complex, multi-client environments needing precise data fetching. tRPC, on the other hand, leverages TypeScript to deliver unparalleled end-to-end type safety and developer experience, making it an excellent choice for full-stack TypeScript applications, especially within monorepos, where simplicity and developer velocity are key. Your choice ultimately depends on your project's specific requirements, your team's familiarity with each technology, and the desired balance between flexibility, type safety, and development speed. Both tools are highly effective at their respective strengths, empowering developers to build robust and maintainable APIs.