Back to Blog
Tutorial10 min read2026-07-03

How to Deploy a tRPC Full-Stack App

tRPC gives you end-to-end type safety without code generation, but deploying it means deciding how server and client ship together. This guide covers the monolith and split-service approaches and the gotchas of each.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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:

  1. 1CORS — the client and API are on different origins, so configure origin to your frontend's exact URL.
  2. 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)
CORSNoneMust configure
Type sharingTrivial (same app)Needs monorepo/package
ScalingScale togetherScale independently
Best forMost appsStatic 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:

  1. 1Deploy the API as a container app; set FRONTEND_ORIGIN to the static site's URL.
  2. 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_URL to the API's URL.
  3. 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 httpBatchLink so 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 createContext and read tokens from headers there.

Verifying

# A query procedure responds (GET with input encoded)
curl -s 'https://api.example.com/trpc/health' | head

A 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).

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also