Next.js with tRPC — Type-Safe APIs

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Next.js with tRPC — Type-Safe APIs

tRPC enables type-safe client-server communication with automatic API route generation, validating data end-to-end.

Installation

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

Server Setup

Create a tRPC router:

// lib/trpc/server.ts
import { initTRPC } from '@trpc/server'
import { ZodError } from 'zod'

const t = initTRPC.create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError ? error.cause.flatten() : null
      }
    }
  }
})

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

Define Procedures

// app/api/trpc/[trpc]/route.ts
import { router, publicProcedure } from '@/lib/trpc/server'
import { z } from 'zod'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'

const appRouter = router({
  // Query
  getPosts: publicProcedure
    .input(z.object({ take: z.number().default(10) }))
    .query(async ({ input }) => {
      return await db.posts.findMany({ take: input.take })
    }),

  // Get by ID
  getPostById: publicProcedure
    .input(z.string())
    .query(async ({ input }) => {
      const post = await db.posts.findUnique({ where: { id: input } })
      if (!post) throw new Error('Post not found')
      return post
    }),

  // Mutation
  createPost: publicProcedure
    .input(z.object({
      title: z.string().min(1),
      content: z.string().min(1)
    }))
    .mutation(async ({ input, ctx }) => {
      return await db.posts.create({
        data: {
          title: input.title,
          content: input.content,
          authorId: ctx.userId
        }
      })
    }),

  // Update
  updatePost: publicProcedure
    .input(z.object({
      id: z.string(),
      title: z.string().optional(),
      content: z.string().optional()
    }))
    .mutation(async ({ input }) => {
      const { id, ...data } = input
      return await db.posts.update({
        where: { id },
        data
      })
    }),

  // Delete
  deletePost: publicProcedure
    .input(z.string())
    .mutation(async ({ input }) => {
      return await db.posts.delete({ where: { id: input } })
    })
})

export type AppRouter = typeof appRouter

export default fetchRequestHandler({
  endpoint: '/api/trpc',
  router: appRouter,
  createContext: async () => {
    return { userId: '123' } // From auth
  }
})

export { handler as GET, handler as POST }

Client Setup

// lib/trpc/client.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/app/api/trpc/[trpc]/route'

export const trpc = createTRPCReact<AppRouter>()

Provider Setup

// app/providers.tsx
'use client'

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

export function Providers({ children }: { children: ReactNode }) {
  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>
  )
}

Using in Components

// app/components/post-list.tsx
'use client'

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

export function PostList() {
  const { data: posts, isLoading } = trpc.getPosts.useQuery({ take: 10 })

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

  return (
    <ul>
      {posts?.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

Mutations

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

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

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

  const createMutation = trpc.createPost.useMutation({
    onSuccess: () => {
      setTitle('')
      setContent('')
      // Invalidate queries to refetch
    }
  })

  async function handleSubmit(e) {
    e.preventDefault()
    await createMutation.mutateAsync({ title, content })
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Title"
        className="w-full border p-2"
      />
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Content"
        className="w-full border p-2"
      />
      <button
        type="submit"
        disabled={createMutation.isPending}
        className="bg-blue-600 text-white px-4 py-2 rounded"
      >
        {createMutation.isPending ? 'Creating...' : 'Create'}
      </button>
    </form>
  )
}

Protected Procedures

// lib/trpc/server.ts
const t = initTRPC.context<Context>().create()

export const protectedProcedure = t.procedure.use(async (opts) => {
  const { ctx } = opts

  if (!ctx.session?.user) {
    throw new Error('Unauthorized')
  }

  return opts.next({
    ctx: {
      session: ctx.session
    }
  })
})

Usage:

const appRouter = router({
  createPost: protectedProcedure
    .input(z.object({ title: z.string(), content: z.string() }))
    .mutation(async ({ input, ctx }) => {
      // ctx.session is guaranteed to exist
      return db.posts.create({
        data: {
          ...input,
          authorId: ctx.session.user.id
        }
      })
    })
})

Error Handling

'use client'

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

export function PostForm() {
  const createMutation = trpc.createPost.useMutation()

  return (
    <div>
      {createMutation.error && (
        <p className="text-red-600">
          {createMutation.error.message}
        </p>
      )}

      <button onClick={() => createMutation.mutate({ title: '', content: '' })}>
        Create
      </button>
    </div>
  )
}

Real-World Example: Blog API

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

export const postsRouter = router({
  list: publicProcedure
    .input(z.object({ page: z.number().default(1), limit: z.number().default(10) }))
    .query(async ({ input }) => {
      const posts = await db.posts.findMany({
        skip: (input.page - 1) * input.limit,
        take: input.limit,
        include: { author: true }
      })
      return posts
    }),

  byId: publicProcedure
    .input(z.string())
    .query(async ({ input }) => {
      return await db.posts.findUnique({
        where: { id: input },
        include: { author: true, comments: true }
      })
    }),

  create: protectedProcedure
    .input(z.object({ title: z.string(), content: z.string() }))
    .mutation(async ({ input, ctx }) => {
      return await db.posts.create({
        data: { ...input, authorId: ctx.session.user.id }
      })
    })
})

FAQ

Q: Is tRPC better than REST APIs? A: tRPC is better for full-stack applications where frontend and backend are tightly coupled. REST is better for public APIs.

Q: Can I use tRPC with external services? A: tRPC is best for backend-to-frontend communication. For external services, use REST or GraphQL.

Q: How do I handle errors in tRPC? A: Throw errors in procedures. tRPC automatically serializes them to the client with proper types.


tRPC eliminates API contract friction and provides incredible developer experience.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro