Next.js with tRPC — Type-Safe APIs
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.
- Next.js with tRPC — Type-Safe APIs
- Installation
- Server Setup
- Define Procedures
- Client Setup
- Provider Setup
- Using in Components
- Mutations
- Protected Procedures
- Error Handling
- Real-World Example: Blog API
- FAQ
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