# How to Deploy a tRPC Full-Stack App
tRPC is a delight to develop with: you define procedures on the server and call them from the client with full TypeScript inference, no schemas, no codegen. The catch comes at deploy time — tRPC's magic depends on client and server sharing types from the same codebase, which shapes how you build and ship. This tutorial covers deploying a tRPC app cleanly.
Two common topologies
tRPC apps usually take one of two shapes:
- 1All-in-one (Next.js / similar). The tRPC router runs inside the same app as the frontend (e.g. a Next.js route handler). One deployable unit. Simplest to ship.
- 2Separate client + server in a monorepo. A standalone tRPC server (Node HTTP/Express/Fastify) and a separate frontend, sharing a types package. Two deployable units, more flexible.
Your choice changes the deploy, so decide early.
The shared-types reality
The core thing to internalize: the client imports the AppRouter type from the server. This is type-only — it is erased at build time and ships no server code to the browser:
// client
import type { AppRouter } from '../server/router';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [httpBatchLink({ url: process.env.NEXT_PUBLIC_API_URL + '/trpc' })],
});For this import to resolve at build time, the client build must have access to the server's source/types — trivial in a monorepo or a single Next.js app, but something to plan for if you split repos.
Option A: Next.js all-in-one
The simplest deploy. The router lives in an API route, the frontend calls it, everything builds and deploys together.
// server side: the router
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { db } from './db';
const t = initTRPC.create();
export const appRouter = t.router({
getTodos: t.procedure.query(() => db.todo.findMany()),
addTodo: t.procedure
.input(z.object({ title: z.string().min(1) }))
.mutation(({ input }) => db.todo.create({ data: input })),
});
export type AppRouter = typeof appRouter;Deploy the Next.js app as a single container. The API and UI ship together, so there is no CORS and no version-skew between client and server types — they are always from the same build.
Option B: standalone server + separate client
When you want the API independent (mobile clients, multiple frontends), run a standalone tRPC server:
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { appRouter } from './router';
createHTTPServer({
router: appRouter,
createContext: ({ req }) => ({ user: authenticate(req) }),
}).listen(Number(process.env.PORT) || 8080);Now you deploy two units: the API container and the static/SSR frontend. You must:
- Configure CORS on the server for the frontend origin.
- Keep the shared types package versioned so client and server stay in sync.
- Point the client at the deployed API URL via a build-time env var.
Step: wire the database
tRPC procedures call your data layer. Read the connection string from the environment, exactly as any Node app:
// db.ts (Prisma example)
import { PrismaClient } from '@prisma/client';
export const db = new PrismaClient();
// Prisma reads DATABASE_URL from the environment automaticallyRun migrations as a release step before traffic arrives:
npx prisma migrate deployStep: containerize
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV PORT=8080
EXPOSE 8080
CMD ["npm", "start"]In a monorepo, make sure the build context includes the shared packages the build needs.
Deployment comparison
| All-in-one (Next.js) | Separate client + server | |
|---|---|---|
| Deployable units | 1 | 2 |
| CORS | None | Required |
| Type sharing | Trivial | Via shared package |
| Best for | Web-only apps | Multiple/mobile clients |
| Complexity | Low | Medium |
Common pitfalls
- Importing server code instead of just the type. Use
import typeso no server logic leaks into the client bundle. - Type skew across split deploys. If client and server deploy independently with mismatched types, you lose tRPC's safety guarantee at runtime. Version the shared package and deploy together.
- Forgetting
prisma migrate deploy. Procedures fail with missing-table errors. - Missing CORS in option B. Browser requests blocked.
Deploying on PandaStack
PandaStack auto-detects Node/Next.js apps or builds from your Dockerfile, so either topology deploys from a git push. For the all-in-one Next.js app, it is a single container deploy. For the split setup, deploy the tRPC server as a web service and the frontend as a static site or container. Provision a managed PostgreSQL and DATABASE_URL is injected automatically, so Prisma connects with zero extra config; you can run prisma migrate deploy as part of your build. Custom domains get automatic SSL, and live logs plus rollbacks make iterating on procedures painless. The free tier (5 web services + 5 static sites + 1 database) covers both topologies.
References
- [tRPC documentation](https://trpc.io/docs)
- [tRPC standalone adapter](https://trpc.io/docs/server/adapters/standalone)
- [Prisma migrate deploy](https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production)
- [Next.js deployment docs](https://nextjs.org/docs/app/building-your-application/deploying)
---
Deploying tRPC is mostly about keeping client and server types in lockstep. PandaStack builds either topology from one git push and auto-wires your database — start free at [dashboard.pandastack.io](https://dashboard.pandastack.io).