tRPC lets your frontend call backend procedures with full TypeScript inference and zero schema duplication — no OpenAPI, no codegen. The deployment question it raises is structural: tRPC is a *transport layer*, not a framework, so where the router runs determines how you ship. Let's cover the two common architectures.
How tRPC fits together
tRPC has three pieces:
- A router of procedures on the server.
- An adapter that mounts the router onto an HTTP server (Next.js, Express, FastH, standalone).
- A typed client that imports the router's *type* (not its code) for inference.
The client only imports type AppRouter, so the server implementation never ships to the browser. That distinction drives your deploy choices.
Architecture A: the Next.js monolith
The most common setup is tRPC inside a Next.js app, served from a route handler. Frontend and backend deploy as one container.
// 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 };Deploy is just a Next.js deploy: next build then next start. One service, one domain, no CORS. This is the right default for most apps.
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=build /app ./
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "start"]Architecture B: split client and server
If your frontend is a static SPA (Vite/React) and the tRPC API is a separate Node service, you deploy two things. The server uses the standalone or Express adapter:
// server/index.ts
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { appRouter } from './router';
import cors from 'cors';
createHTTPServer({
router: appRouter,
middleware: cors({ origin: process.env.FRONTEND_ORIGIN }),
createContext: () => ({})
}).listen(Number(process.env.PORT ?? 3000));The two gotchas here:
- 1CORS — the client and API are on different origins, so configure
originto your frontend's exact URL. - 2Type sharing — the client needs
AppRouter's type at build time. In a monorepo, share it via a package; otherwise publish the types. This is the friction the monolith avoids.
For the static client, the tRPC client points at the API URL:
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';
export const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: import.meta.env.VITE_API_URL })]
});Which to choose
| Monolith (Next.js) | Split (SPA + API) | |
|---|---|---|
| CORS | None | Must configure |
| Type sharing | Trivial (same app) | Needs monorepo/package |
| Scaling | Scale together | Scale independently |
| Best for | Most apps | Static frontend + heavy API |
If you are unsure, start with the monolith.
Deploying on PandaStack
Monolith: deploy the Next.js app as a container app (Dockerfile auto-detected, or Node buildpack). Add a managed PostgreSQL if you have one — DATABASE_URL is injected. Attach a domain; SSL is automatic.
Split:
- 1Deploy the API as a container app; set
FRONTEND_ORIGINto the static site's URL. - 2Deploy the Vite client as a static site — PandaStack auto-detects the framework, builds it in its static-build microVMs, and serves it on a CDN-backed domain. Set
VITE_API_URLto the API's URL. - 3Both get automatic SSL and custom domains.
The split approach pairs nicely with the platform's separate static and container product types — static frontends are cheap and fast to serve, while the API runs as a proper long-lived service.
Production notes
- Use
httpBatchLinkso multiple calls in one render coalesce into a single request — fewer round trips. - Add input validation with Zod in your procedures; tRPC infers the types from it.
- Put auth/context creation in
createContextand read tokens from headers there.
Verifying
# A query procedure responds (GET with input encoded)
curl -s 'https://api.example.com/trpc/health' | headA valid tRPC JSON envelope means the router is mounted and reachable.
References
- [tRPC server adapters](https://trpc.io/docs/server/adapters)
- [tRPC with Next.js](https://trpc.io/docs/client/nextjs)
- [tRPC HTTP batch link](https://trpc.io/docs/client/links/httpBatchLink)
- [tRPC CORS handling](https://trpc.io/docs/server/adapters/standalone)
---
Whether you ship a tRPC monolith or split the SPA from the API, PandaStack covers both with container apps and static sites, plus managed databases auto-wired. Start free at [dashboard.pandastack.io](https://dashboard.pandastack.io).