March 10, 2026·8 min·By

tRPC: End-to-End TypeScript APIs Without Code Generation

tRPCTypeScriptNext.jsAPItype safety

tRPC gives you end-to-end TypeScript type safety between your server and client without generating code or writing schema files. Your editor knows the API response type before you even call it.

Setup

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

Define Your Router

// server/api/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { ZodError } from 'zod'

const t = initTRPC.context<{ userId: string | null }>().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
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
    if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED' })
    return next({ ctx: { userId: ctx.userId } })  // userId is now non-null
})
// server/api/routers/posts.ts
import { z } from 'zod'
import { router, publicProcedure, protectedProcedure } from '../trpc'

export const postsRouter = router({
    list: publicProcedure
        .input(z.object({
            cursor: z.string().optional(),
            limit: z.number().min(1).max(100).default(20)
        }))
        .query(async ({ input }) => {
            const posts = await db.posts.findMany({
                take: input.limit + 1,
                cursor: input.cursor ? { id: input.cursor } : undefined,
                orderBy: { createdAt: 'desc' }
            })
            const hasMore = posts.length > input.limit
            return {
                posts: posts.slice(0, input.limit),
                nextCursor: hasMore ? posts[input.limit - 1].id : null
            }
        }),

    create: protectedProcedure
        .input(z.object({
            title: z.string().min(1).max(200),
            content: z.string().min(10)
        }))
        .mutation(async ({ ctx, input }) => {
            return db.posts.create({
                data: { ...input, authorId: ctx.userId }
            })
        }),

    delete: protectedProcedure
        .input(z.string())
        .mutation(async ({ ctx, input: postId }) => {
            const post = await db.posts.findUnique({ where: { id: postId } })
            if (!post) throw new TRPCError({ code: 'NOT_FOUND' })
            if (post.authorId !== ctx.userId) throw new TRPCError({ code: 'FORBIDDEN' })
            return db.posts.delete({ where: { id: postId } })
        })
})
// server/api/root.ts
import { router } from './trpc'
import { postsRouter } from './routers/posts'
import { usersRouter } from './routers/users'

export const appRouter = router({
    posts: postsRouter,
    users: usersRouter
})

export type AppRouter = typeof appRouter

Next.js Route Handler

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/api/root'
import { getServerSession } from '@/lib/auth'

const handler = (req: Request) =>
    fetchRequestHandler({
        endpoint: '/api/trpc',
        req,
        router: appRouter,
        createContext: async () => {
            const session = await getServerSession()
            return { userId: session?.userId ?? null }
        }
    })

export { handler as GET, handler as POST }

Client Usage

// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/api/root'

export const trpc = createTRPCReact<AppRouter>()
// components/PostList.tsx
'use client'
import { trpc } from '@/lib/trpc'

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

    // data.posts is fully typed -- editor autocompletes every field
    return (
        <div>
            {data?.posts.map(post => (
                <article key={post.id}>
                    <h2>{post.title}</h2>  {/* TypeScript knows this exists */}
                    <p>{post.content}</p>
                </article>
            ))}
        </div>
    )
}

// Mutation with optimistic update
export function CreatePost() {
    const utils = trpc.useUtils()
    const create = trpc.posts.create.useMutation({
        onSuccess: () => {
            utils.posts.list.invalidate()  // Refetch list after create
        }
    })

    return (
        <button onClick={() => create.mutate({ title: 'Test', content: 'Content here' })}>
            Create Post
        </button>
    )
}

tRPC vs REST for Next.js

tRPC REST (Route Handlers)
Type safety End-to-end, automatic Manual or OpenAPI
External consumers No (TypeScript only) Yes
Learning curve Low (it's just TypeScript) Low
Flexibility Less (TypeScript ecosystem) More (any client)
Code generation None needed Optional (OpenAPI)

Use tRPC for: Next.js full-stack apps with TypeScript, internal tools, monorepos where frontend and backend share types.

Use REST when: you need a public API, have mobile/non-TypeScript clients, or the team prefers REST conventions.

The productivity gain from tRPC is real -- renaming a field on the server immediately shows errors in every client that uses it, without running tests or type generation.

K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

When should I use Server Components vs Client Components?+

Server Components are the default — use them for data fetching, static content, and anything that doesn't need interactivity. Only switch to Client Components when you need browser APIs, state, effects, or event handlers. The less JS you ship to the browser, the faster your site.

Why is my Next.js build so slow?+

Usually it's TypeScript type-checking or a large dependency. Try `NEXT_TELEMETRY_DISABLED=1 next build` with `--profile` and look at what's taking time. Splitting large data files and enabling turborepo are the two biggest wins.

Should I use the App Router or Pages Router for a new project?+

App Router for new projects, full stop. Pages Router still works fine but receives fewer improvements. The learning curve for App Router is steep for one week, then you never want to go back.

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously — but we'll tell you honestly when in-house makes more sense.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation