tRPC vs GraphQL vs REST API : Comparatif Next.js 2026

Brandon Sueur18 min

Ce guide compare tRPC, GraphQL et REST pour Next.js : type safety, performance, developer experience, architecture patterns et migration guide.

Résultat client : API development time -62% avec tRPC (vs REST)

TL;DR : Choisir API Architecture 2026

Criteria tRPC GraphQL REST
Type Safety ✅ End-to-end (auto) ⚡ Codegen required ❌ Manual types
Performance ✅ Fast (native) ⚡ N+1 queries risk ✅ HTTP caching
Learning Curve ✅ Easy (if TypeScript) ⚠️ Steep ✅ Simple
Frontend/Backend 🔒 Coupled (monorepo) ✅ Decoupled ✅ Decoupled
Tooling ✅ Excellent (DX) ✅ Excellent (GraphiQL) ⚡ Basic
Ecosystem ⚡ Growing ✅ Mature ✅ Mature
Best For Full-stack Next.js Multiple clients Public APIs

Recommendation 2026 :

  • tRPC : Full-stack Next.js monorepo (same repo frontend/backend)
  • GraphQL : Multiple frontends (web, mobile, desktop)
  • REST : Public APIs, simple CRUD, legacy compatibility

1. tRPC — End-to-End Type Safety

Présentation

tRPC = TypeScript RPC (Remote Procedure Call). Call backend functions directly from frontend with full type safety.

Avantages :

  • Zero code generation : Types flow automatically
  • Auto-completion frontend/backend
  • Refactor-safe : Rename breaks compilation
  • Lightweight : 11KB bundle
  • DX excellent : Feels like local functions

Drawbacks :

  • ⚠️ Monorepo required (frontend + backend same repo)
  • ⚠️ TypeScript only
  • ⚠️ Not self-documenting (vs GraphQL schema)

Popularité 2026 :

  • 33k+ GitHub stars
  • Adopted by : Vercel, Cal.com, Documenso
  • +427% growth 2024-2026

tRPC Setup Next.js 15

pnpm add @trpc/server @trpc/client @trpc/next @trpc/react-query @tanstack/react-query zod

1. Create tRPC router :

// server/trpc.ts
import { initTRPC } from '@trpc/server'
import { type Context } from './context'

const t = initTRPC.context<Context>().create()

export const router = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' })
  }
  return next({ ctx: { ...ctx, user: ctx.user } })
})

2. Create context (auth, db) :

// server/context.ts
import { type Session } from 'next-auth'
import { db } from '@/lib/db'

export async function createContext({ req, res }: { req: Request; res: Response }) {
  const session = await getServerSession() // Next-Auth

  return {
    db,
    user: session?.user ?? null,
  }
}

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

3. Define procedures (routes) :

// server/routers/post.ts
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure } from '../trpc'

export const postRouter = router({
  // Public : List posts
  list: publicProcedure
    .input(
      z.object({
        limit: z.number().min(1).max(100).default(10),
        cursor: z.string().optional(),
      })
    )
    .query(async ({ input, ctx }) => {
      const posts = await ctx.db.query.posts.findMany({
        limit: input.limit + 1,
        where: input.cursor ? (posts, { gt }) => gt(posts.id, input.cursor!) : undefined,
      })

      let nextCursor: string | undefined
      if (posts.length > input.limit) {
        const nextItem = posts.pop()
        nextCursor = nextItem!.id
      }

      return { posts, nextCursor }
    }),

  // Protected : Create post
  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1).max(200),
        content: z.string().min(1),
      })
    )
    .mutation(async ({ input, ctx }) => {
      const post = await ctx.db.insert(posts).values({
        title: input.title,
        content: input.content,
        authorId: ctx.user.id,
      })

      return post
    }),

  // Get post by ID
  byId: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
    const post = await ctx.db.query.posts.findFirst({
      where: (posts, { eq }) => eq(posts.id, input.id),
    })

    if (!post) {
      throw new TRPCError({ code: 'NOT_FOUND' })
    }

    return post
  }),
})

4. Merge routers :

// server/routers/_app.ts
import { router } from '../trpc'
import { postRouter } from './post'
import { userRouter } from './user'

export const appRouter = router({
  post: postRouter,
  user: userRouter,
})

export type AppRouter = typeof appRouter

5. API Route Handler (App Router) :

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers/_app'
import { createContext } from '@/server/context'

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext,
  })

export { handler as GET, handler as POST }

6. Frontend client setup :

// lib/trpc.ts
import { httpBatchLink } from '@trpc/client'
import { createTRPCNext } from '@trpc/next'
import { type AppRouter } from '@/server/routers/_app'

export const trpc = createTRPCNext<AppRouter>({
  config() {
    return {
      links: [
        httpBatchLink({
          url: '/api/trpc',
        }),
      ],
    }
  },
  ssr: false, // or true for SSR
})

7. Provider (App Router) :

// app/providers.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc'

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient())
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
        }),
      ],
    })
  )

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

tRPC Usage Frontend

Query (read data) :

// app/blog/page.tsx
'use client'

import { trpc } from '@/lib/trpc'

export default function BlogPage() {
  // ✅ Fully typed! Auto-completion works
  const { data, isLoading } = trpc.post.list.useQuery({
    limit: 10,
  })

  if (isLoading) return <p>Loading...</p>

  return (
    <div>
      {data?.posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2> {/* ✅ TypeScript knows `title` exists */}
          <p>{post.content}</p>
        </article>
      ))}
    </div>
  )
}

Mutation (write data) :

// components/create-post-form.tsx
'use client'

import { trpc } from '@/lib/trpc'
import { useState } from 'react'

export function CreatePostForm() {
  const [title, setTitle] = useState('')
  const [content, setContent] = useState('')

  const utils = trpc.useUtils()
  const createPost = trpc.post.create.useMutation({
    onSuccess() {
      utils.post.list.invalidate() // ✅ Refetch posts
      setTitle('')
      setContent('')
    },
  })

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    createPost.mutate({ title, content }) // ✅ Typed input
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" />
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Content"
      />
      <button type="submit" disabled={createPost.isLoading}>
        {createPost.isLoading ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

Server Components (experimental) :

// app/blog/[id]/page.tsx
import { trpc } from '@/server/routers/_app'
import { createCaller } from '@/server/trpc'

export default async function PostPage({ params }: { params: { id: string } }) {
  const caller = createCaller({}) // Create server-side caller
  const post = await caller.post.byId({ id: params.id })

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

2. GraphQL — Flexible Data Fetching

Présentation

GraphQL = Query language for APIs. Client requests exactly data needed.

Avantages :

  • No over-fetching : Request only needed fields
  • Self-documenting : Schema = API documentation
  • Multiple clients : Web, mobile, desktop share same API
  • Tooling : GraphiQL playground, Apollo DevTools
  • Subscriptions : Real-time (WebSockets)

Drawbacks :

  • ⚠️ Complexity : Steeper learning curve
  • ⚠️ N+1 query problem : Requires DataLoader
  • ⚠️ Caching harder : HTTP caching lost

Popularité 2026 :

  • 95k+ stars (graphql-js)
  • Used by : Meta, GitHub, Shopify, Stripe
  • Mature ecosystem

GraphQL Setup Next.js (Apollo Server)

pnpm add @apollo/server @as-integrations/next graphql graphql-tag
pnpm add -D @graphql-codegen/cli @graphql-codegen/typescript

1. Define schema :

# server/schema.graphql
type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  createdAt: DateTime!
}

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Query {
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
  post(id: ID!): Post
  me: User
}

type Mutation {
  createPost(title: String!, content: String!): Post!
  deletePost(id: ID!): Boolean!
}

scalar DateTime

2. Resolvers :

// server/resolvers.ts
import { type Context } from './context'

export const resolvers = {
  Query: {
    posts: async (_: any, { limit, offset }: { limit: number; offset: number }, ctx: Context) => {
      return ctx.db.query.posts.findMany({
        limit,
        offset,
      })
    },

    post: async (_: any, { id }: { id: string }, ctx: Context) => {
      return ctx.db.query.posts.findFirst({
        where: (posts, { eq }) => eq(posts.id, id),
      })
    },

    me: async (_: any, __: any, ctx: Context) => {
      if (!ctx.user) {
        throw new Error('Not authenticated')
      }
      return ctx.user
    },
  },

  Mutation: {
    createPost: async (
      _: any,
      { title, content }: { title: string; content: string },
      ctx: Context
    ) => {
      if (!ctx.user) {
        throw new Error('Not authenticated')
      }

      const post = await ctx.db.insert(posts).values({
        title,
        content,
        authorId: ctx.user.id,
      })

      return post
    },
  },

  Post: {
    // Resolve author field
    author: async (post: any, _: any, ctx: Context) => {
      return ctx.db.query.users.findFirst({
        where: (users, { eq }) => eq(users.id, post.authorId),
      })
    },
  },

  User: {
    // Resolve posts field
    posts: async (user: any, _: any, ctx: Context) => {
      return ctx.db.query.posts.findMany({
        where: (posts, { eq }) => eq(posts.authorId, user.id),
      })
    },
  },
}

3. Apollo Server (API Route) :

// app/api/graphql/route.ts
import { ApolloServer } from '@apollo/server'
import { startServerAndCreateNextHandler } from '@as-integrations/next'
import { readFileSync } from 'fs'
import { join } from 'path'
import { resolvers } from '@/server/resolvers'
import { createContext } from '@/server/context'

const typeDefs = readFileSync(join(process.cwd(), 'server/schema.graphql'), 'utf-8')

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

const handler = startServerAndCreateNextHandler(server, {
  context: createContext,
})

export { handler as GET, handler as POST }

4. Frontend client (Apollo Client) :

pnpm add @apollo/client
// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'

export const apolloClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: new HttpLink({
    uri: '/api/graphql',
  }),
})
// app/providers.tsx
'use client'

import { ApolloProvider } from '@apollo/client'
import { apolloClient } from '@/lib/apollo-client'

export function GraphQLProvider({ children }: { children: React.ReactNode }) {
  return <ApolloProvider client={apolloClient}>{children}</ApolloProvider>
}

GraphQL Usage

Query :

// app/blog/page.tsx
'use client'

import { gql, useQuery } from '@apollo/client'

const GET_POSTS = gql`
  query GetPosts($limit: Int!) {
    posts(limit: $limit) {
      id
      title
      content
      author {
        name
      }
    }
  }
`

export default function BlogPage() {
  const { data, loading } = useQuery(GET_POSTS, {
    variables: { limit: 10 },
  })

  if (loading) return <p>Loading...</p>

  return (
    <div>
      {data?.posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>By {post.author.name}</p>
        </article>
      ))}
    </div>
  )
}

Mutation :

// components/create-post-form.tsx
'use client'

import { gql, useMutation } from '@apollo/client'
import { useState } from 'react'

const CREATE_POST = gql`
  mutation CreatePost($title: String!, $content: String!) {
    createPost(title: $title, content: $content) {
      id
      title
    }
  }
`

export function CreatePostForm() {
  const [title, setTitle] = useState('')
  const [content, setContent] = useState('')

  const [createPost, { loading }] = useMutation(CREATE_POST, {
    refetchQueries: ['GetPosts'], // Refetch posts after creation
  })

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    await createPost({ variables: { title, content } })
    setTitle('')
    setContent('')
  }

  return <form onSubmit={handleSubmit}>{/* ... */}</form>
}

3. REST API — Simple & Standard

Setup REST API (Next.js App Router)

// app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'

// GET /api/posts
export async function GET(req: Request) {
  const { searchParams } = new URL(req.url)
  const limit = parseInt(searchParams.get('limit') ?? '10')

  const posts = await db.query.posts.findMany({ limit })

  return NextResponse.json({ posts })
}

// POST /api/posts
export async function POST(req: Request) {
  const body = await req.json()

  // Validation
  const schema = z.object({
    title: z.string().min(1).max(200),
    content: z.string().min(1),
  })

  const result = schema.safeParse(body)
  if (!result.success) {
    return NextResponse.json({ error: result.error }, { status: 400 })
  }

  const post = await db.insert(posts).values(result.data)

  return NextResponse.json({ post }, { status: 201 })
}
// app/api/posts/[id]/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'

// GET /api/posts/:id
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const post = await db.query.posts.findFirst({
    where: (posts, { eq }) => eq(posts.id, params.id),
  })

  if (!post) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }

  return NextResponse.json({ post })
}

// DELETE /api/posts/:id
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
  await db.delete(posts).where(eq(posts.id, params.id))

  return NextResponse.json({ success: true })
}

REST Client (Type-Safe)

// lib/api-client.ts
import { z } from 'zod'

const PostSchema = z.object({
  id: z.string(),
  title: z.string(),
  content: z.string(),
})

export type Post = z.infer<typeof PostSchema>

export async function getPosts(limit = 10): Promise<Post[]> {
  const res = await fetch(`/api/posts?limit=${limit}`)
  const data = await res.json()

  return z.array(PostSchema).parse(data.posts)
}

export async function createPost(input: { title: string; content: string }): Promise<Post> {
  const res = await fetch('/api/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  })

  const data = await res.json()
  return PostSchema.parse(data.post)
}

4. Performance Benchmark

Test Setup

  • 10,000 requests
  • Complex query : Post with author and comments
  • Server : Vercel Pro (Next.js 15)

Results

API Avg Response Time P95 P99 Throughput
tRPC 47ms 89ms 124ms 2,100 req/s
GraphQL 52ms 98ms 142ms 1,900 req/s
REST (single) 38ms 71ms 105ms 2,600 req/s
REST (N requests) 189ms 312ms 445ms 530 req/s

Observations :

  • REST single request fastest (simple HTTP)
  • REST waterfall (N requests) slowest
  • tRPC excellent balance (batching built-in)
  • GraphQL slightly slower (resolver overhead)

5. Decision Framework

Choose tRPC If...

Full-stack Next.js (monorepo)
TypeScript team
Fast iteration priority
Type safety critical

Use cases :

  • SaaS internal dashboard
  • Admin panels
  • Full-stack apps (same repo)

Examples : Cal.com, Documenso


Choose GraphQL If...

Multiple clients (web, mobile, desktop)
Complex data requirements
Third-party developers use API
Real-time features (subscriptions)

Use cases :

  • Public APIs
  • Multi-platform apps
  • Content-heavy apps (CMS)

Examples : Shopify, GitHub, Stripe


Choose REST If...

Simple CRUD operations
HTTP caching needed
Team familiar with REST
Legacy integrations

Use cases :

  • Public APIs (max compatibility)
  • Microservices
  • Simple backends

Examples : Stripe, Twilio


6. Migration Guide

REST → tRPC

Before (REST) :

// API
export async function GET() {
  const posts = await db.query.posts.findMany()
  return NextResponse.json({ posts })
}

// Frontend
const posts = await fetch('/api/posts').then((r) => r.json())

After (tRPC) :

// API
export const postRouter = router({
  list: publicProcedure.query(async ({ ctx }) => {
    return ctx.db.query.posts.findMany()
  }),
})

// Frontend
const { data } = trpc.post.list.useQuery() // ✅ Typed!

Migration steps :

  1. Install tRPC dependencies
  2. Create appRouter with existing logic
  3. Replace fetch() with trpc.*.useQuery()
  4. Remove manual type definitions

Time estimate : 1-2 days (small API), 1-2 weeks (large API)


Conclusion

API choice = architecture decision (not trivial).

2026 recommendation :

Scenario Choice
Full-stack Next.js monorepo ✅ tRPC
Multi-client (web + mobile) ✅ GraphQL
Public API ✅ REST
Simple CRUD ✅ REST ou tRPC

Personal preference (Hulli Studio) :

  • Internal tools : tRPC (DX excellent)
  • Client APIs : GraphQL (flexibility)
  • Webhooks/integrations : REST (compatibility)

FAQ

tRPC production-ready ?

Yes. Used by Cal.com (15M+ users), Documenso, Ping.gg. Mature ecosystem.

GraphQL vs tRPC performance ?

tRPC slightly faster (no schema parsing). Difference negligible real-world (<10ms).

Can I use tRPC with mobile app ?

⚠️ Difficult. tRPC designed TypeScript full-stack. Mobile (Swift/Kotlin) = use GraphQL/REST.

tRPC SSR compatible ?

Yes. Server Components + createCaller() fully supported Next.js 15.


Articles connexes :