Next.js + tRPC Guide — End-to-End Type Safety Best Practices 2026

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

Most Next.js applications eventually need a typed API contract between the client and server. REST APIs require manual type synchronization; GraphQL requires a code generator. tRPC takes a different approach: your TypeScript types are the contract, inferred automatically from the router you define on the server.

The result is an end-to-end type-safe stack where renaming a server procedure immediately shows a TypeScript error in every client call — no schema files, no code generation, no runtime surprises.

Project Setup

Create a Next.js project and install tRPC with its React Query adapter:

npx create-next-app@latest my-trpc-app --typescript --app
cd my-trpc-app
npm install @trpc/client @trpc/server @trpc/react-query @tanstack/react-query zod

Building the tRPC Server

Create src/server/trpc.ts — the tRPC initializer:

import { initTRPC } from '@trpc/server'
import { z } from 'zod'
 
const t = initTRPC.create()
 
export const router = t.router
export const publicProcedure = t.procedure
export const middleware = t.middleware

Define your application router in src/server/index.ts:

import { z } from 'zod'
import { publicProcedure, router } from './trpc'
 
export const appRouter = router({
  // Query: read data
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const user = await db.user.findById(input.id)
      if (!user) throw new TRPCError({ code: 'NOT_FOUND' })
      return user
    }),
 
  // Mutation: write data
  createPost: publicProcedure
    .input(
      z.object({
        title: z.string().min(1).max(200),
        content: z.string().min(10),
      })
    )
    .mutation(async ({ input }) => {
      return db.post.create({ data: input })
    }),
})
 
// Export the type — used by the client for inference
export type AppRouter = typeof appRouter

Wire up the HTTP handler in app/api/trpc/[trpc]/route.ts:

import { appRouter } from '@/server'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
 
const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => ({}),
  })
 
export { handler as GET, handler as POST }

Configuring tRPC Clients

Create app/_trpc/client.ts for Client Components:

import { type AppRouter } from '@/server'
import { createTRPCReact } from '@trpc/react-query'
 
export const trpc = createTRPCReact<AppRouter>()

Create app/_trpc/serverClient.ts for Server Components:

import { appRouter } from '@/server'
import { createCallerFactory } from '@trpc/server'
 
const createCaller = createCallerFactory(appRouter)
export const serverClient = createCaller({})

Set up the React Query provider in app/providers.tsx:

'use client'
 
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { useState } from 'react'
import { trpc } from './_trpc/client'
 
export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient())
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: `${process.env.NEXT_PUBLIC_APP_URL}/api/trpc`,
        }),
      ],
    })
  )
 
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  )
}

Using tRPC in Server Components

Server Components can call tRPC procedures directly — no HTTP overhead:

// app/users/[id]/page.tsx
import { serverClient } from '@/app/_trpc/serverClient'
 
export default async function UserPage({ params }: { params: { id: string } }) {
  // Direct server-side call — fully type-safe, no network round-trip
  const user = await serverClient.getUser({ id: params.id })
 
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

Using tRPC in Client Components

Client Components use the React Query hooks for reactive data fetching:

'use client'
 
import { trpc } from '@/app/_trpc/client'
 
export function CreatePostForm() {
  const utils = trpc.useUtils()
 
  const createPost = trpc.createPost.useMutation({
    onSuccess: () => {
      utils.getPosts.invalidate()  // Refetch posts after creation
    },
  })
 
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const data = new FormData(e.currentTarget)
    await createPost.mutateAsync({
      title: data.get('title') as string,
      content: data.get('content') as string,
    })
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit" disabled={createPost.isPending}>
        {createPost.isPending ? 'Creating...' : 'Create Post'}
      </button>
      {createPost.error && <p className="error">{createPost.error.message}</p>}
    </form>
  )
}

Common Mistakes

Forgetting to export AppRouter type. The client needs the type export to infer procedure signatures. If you restructure your router, ensure the type export stays at the module root.

Using createCaller on the client. The server caller bypasses HTTP entirely — it should only be used in Server Components and API routes, never in 'use client' files.

Not handling TRPCError on the client. tRPC wraps server errors into structured objects. Check error.data?.code for tRPC-specific codes (NOT_FOUND, UNAUTHORIZED, etc.) rather than comparing HTTP status codes.

Skipping Zod input validation. Every public procedure should validate its input with .input(zodSchema). Without it, malformed requests reach your business logic unchecked.

Best Practices

  • Split routers by domain. Use router({ users: userRouter, posts: postRouter }) at the top level and define each sub-router in its own file.
  • Add authentication context. Pass the session to createContext and create an authedProcedure middleware that checks it before any protected route runs.
  • Enable request batching. httpBatchLink (used above) batches concurrent tRPC calls into a single HTTP request, reducing round-trips on page load.
  • Use superjson transformer. Add transformer: superjson to handle Date, Map, Set, and BigInt serialization transparently across the wire.
  • Deploy the production URL conditionally. Read process.env.NEXT_PUBLIC_APP_URL so the tRPC client sends requests to the correct origin in both development and production.

Key Takeaways

  • tRPC infers client types directly from the server router — there is no schema file or code generation step.
  • The AppRouter type export is the only artifact shared between server and client; everything else is inferred automatically.
  • Server Components should use createCaller to call tRPC procedures without an HTTP round-trip.
  • Client Components use trpc.<procedure>.useQuery() and .useMutation() backed by React Query — caching, invalidation, and loading states come for free.
  • Every procedure should validate its input with a Zod schema passed to .input() to prevent malformed data from reaching business logic.
  • tRPC supports protected procedures via middleware — create an authedProcedure that reads the session from context before executing.
  • httpBatchLink collapses concurrent procedure calls into one HTTP request, significantly reducing latency on initial page load.
  • tRPC error codes (NOT_FOUND, UNAUTHORIZED, BAD_REQUEST) map to HTTP status codes automatically, making API debugging straightforward.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading