
The Mental Model I Use for React Server Components
After six months of production RSC usage, one reframe made everything click: stop thinking about components and start thinking about render boundaries.

The API contract problem is one of those things that seems solved until it isn't. You write a REST endpoint, define the response shape in your head, consume it on the frontend, and everything works - until someone changes the backend and the frontend silently breaks at runtime.
tRPC takes a different approach: it makes the contract impossible to violate at compile time.
tRPC doesn't generate code. There's no openapi.yaml, no Swagger spec, no separate schema file. Instead, you write regular TypeScript functions on the server and call them directly from the client - with full type inference flowing through.
The trick is that tRPC infers the input and output types of your router procedures, and exposes them to the client through a shared type. No network call needed to get the types - TypeScript figures it out statically.
// server/router.ts
import { z } from 'zod'
import { router, publicProcedure } from './trpc'
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.user.findUnique({ where: { id: input.id } })
}),
})
export type AppRouter = typeof appRouter
On the client:
// components/UserCard.tsx
const { data: user } = trpc.getUser.useQuery({ id: userId })
// user is typed as User | null,no manual typing required
If you rename getUser on the server, TypeScript immediately flags every usage on the client. If you change the return shape, every consumer breaks at compile time. The feedback loop that used to require a runtime error in production now happens in your editor.
The App Router integration requires a bit more setup than the Pages Router, but it's worth it.
First, install the packages:
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
Create your tRPC server instance:
// server/trpc.ts
import { initTRPC } from '@trpc/server'
import { cache } from 'react'
const createTRPCContext = cache(async () => ({
// your context here,db connections, auth session, etc.
}))
const t = initTRPC.context<typeof createTRPCContext>().create()
export const router = t.router
export const publicProcedure = t.procedure
Wire up the API route handler:
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/router'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
})
export { handler as GET, handler as POST }
tRPC shines when both the client and server are TypeScript and both live in the same repo (or monorepo). The zero-codegen DX is genuinely excellent,you add a procedure, use it on the client, done.
Where REST still makes sense: public APIs consumed by external clients, or when your backend is polyglot. tRPC is a TypeScript-to-TypeScript transport layer,that's both its superpower and its constraint.
After using it across four different projects, my default for full-stack Next.js work is now tRPC unless there's a specific reason for REST. The type safety dividends compound over time, especially when the team grows and people are modifying endpoints they didn't originally write.
The next evolution from here is tRPC with Zod coercion for complex input validation. But that's a post for another day.

After six months of production RSC usage, one reframe made everything click: stop thinking about components and start thinking about render boundaries.

Railway removed all the reasons I had to use Vercel. Here's a complete walkthrough including environment variables, Postgres, and custom domains.