tRPC + Zod: Minimal Type‑Safe RPC Design for Same‑Origin Apps
Build a minimal, type‑safe tRPC layer with Zod validation and middleware‑based auth for same‑origin apps. This guide covers requirements, design, trust boundaries, ops checks, failure modes, and when to pivot to a different API stack.
30 Dec 2025, 12:34 UTC

Requirements
For a single‑origin web application that wants end‑to‑end type safety without duplicating schemas in a REST layer, the key requirements are:
- Compile‑time type safety – the client’s procedure calls must be checked against the server’s definitions in TypeScript.
- Runtime validation – malformed or malicious payloads must be rejected before any business logic runs.
- Server‑controlled authorization – only authenticated users may invoke protected procedures; the client cannot bypass this.
- No external API gateway or schema registry – the same codebase should ship both client and server.
- Operational observability – logs and metrics should expose procedure name, duration, and validation failures.
Smallest Suitable Design
The leanest architecture that satisfies these needs is a single trpc router that exposes both queries and mutations, each with an explicit Zod input schema. The server creates a per‑request context that includes the authenticated user (or null), and a middleware that attaches this user before any resolver runs.
Below is a minimal, but production‑ready, example for a Node.js + Express backend and a React client.
// server/src/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { createContext } from './context';
const t = initTRPC.context<Context>().create();
export const appRouter = t.router({
getProfile: t.procedure
.use(authMiddleware)
.query(() => {
// The user is guaranteed to exist because authMiddleware rejects unauthenticated
return { name: ctx.user.name, email: ctx.user.email };
}),
updateProfile: t.procedure
.use(authMiddleware)
.input(z.object({ name: z.string().min(1) }))
.mutation(({ input, ctx }) => {
// Update logic here
return { success: true };
}),
});
export type AppRouter = typeof appRouter;
// server/src/context.ts
import { inferAsyncReturnType } from '@trpc/server';
import type { Request, Response } from 'express';
import { getSession } from './auth'; // your session helper
export const createContext = async ({ req, res }: { req: Request; res: Response }) => {
const session = await getSession(req);
return { req, res, user: session?.user ?? null };
};
export type Context = inferAsyncReturnType;
// server/src/middleware.ts
import { TRPCError } from '@trpc/server';
import { t } from './router';
export const authMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not signed in' });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
On the client side, tRPC’s code‑generation tooling will produce fully typed hooks that reference the same AppRouter type, ensuring compile‑time safety.
// client/src/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/src/router';
import superjson from 'superjson';
export const trpc = createTRPCReact<AppRouter>();
export const trpcClient = trpc.createClient({
url: '/trpc',
transformer: superjson,
});
Running the server requires the usual Node.js environment. The following commands build and start the app:
# Build server
npm run build
# Start
node dist/server/index.js
Trust & Data Boundaries
The HTTP layer is the primary trust boundary. The browser is untrusted; the server is trusted. tRPC transports typed payloads, but the transport itself does not perform authentication or authorization. Therefore, the authMiddleware must run before any resolver that touches protected data. If the middleware is omitted or reordered, the resolver could see a null user and potentially return sensitive information or cause a null‑reference error.
Because the client bundle includes the router shape, procedure names are discoverable. To mitigate accidental exposure of internal services, keep the router surface minimal and document public procedures only.
Operational Checks
- Structured logging – each procedure should log its name and duration. In Express, you can add a global middleware that records
req.trpcProcedureandprocess.hrtime().- Example log:
{\"procedure\":\"getProfile\",\"durationMs\":12,\"userId\":42}
- Example log:
- Validation failure metrics – count Zod validation errors per endpoint. A sudden spike indicates a client‑server schema mismatch.
- Error formatting – expose a consistent error shape (code, message, optional details) so the client can render user‑friendly messages without leaking stack traces.
- Health checks – a simple
/healthendpoint that returns200 OKif the server can resolve a trivial procedure.
Failure Modes
1. Client‑Server Type Drift – if the client is built against an older router, the server may reject the request with a Zod error. Detect this via validation metrics and enforce a release cadence that keeps client and server in sync.
2. Unauthenticated Context Leakage – if the authMiddleware is bypassed or mis‑ordered, resolvers may run with ctx.user === null and inadvertently expose data or crash. Guard against this by testing unauthenticated requests in CI and ensuring middleware order in initTRPC.
3. Large Payloads – tRPC does not stream by default. A mutation that returns a large dataset can exhaust memory or increase latency. Use pagination or split procedures, and set a request body size limit in Express (app.use(express.json({ limit: '1mb' }))).
Design‑Change Triggers
When the application evolves beyond a same‑origin, tightly coupled monorepo, the minimal tRPC design may no longer suffice. Consider the following triggers:
- Cross‑origin clients – If third‑party apps or mobile clients need to consume the API, you’ll need CORS, token‑based auth, and possibly an API gateway to enforce rate limits and request shaping.
- Publicly exposed data – Exposing data to the web without authentication requires careful schema design and possibly GraphQL to provide flexible query filtering.
- Streaming or real‑time data – tRPC supports WebSocket transports, but if you need long‑running streams (e.g., server‑sent events), a dedicated streaming layer or gRPC might be more appropriate.
- Versioned contracts – When clients cannot be updated simultaneously, you need a versioned router (e.g.,
v1,v2) and deprecation strategy.
When any of these conditions arise, evaluate whether a REST or GraphQL gateway with explicit OpenAPI/GraphQL schemas, rate limiting, and policy enforcement is a better fit.
Practical Verification Checklist
- Run
npm teston both client and server to catch compile‑time mismatches. - Deploy to a staging environment and perform a smoke test: send a signed‑in request to
getProfile, verify a 200 response, and check logs for the procedure name. - Send an unauthenticated request; ensure the server returns a
401 UNAUTHORIZEDerror and no user data is leaked. - Simulate a payload that violates the Zod schema; confirm a
400 BAD REQUESTwith a clear validation error message. - Monitor validation error counts in your metrics dashboard; a sudden increase should trigger an alert.
By following this architecture note, you can confidently deploy a minimal, type‑safe tRPC layer that meets the core needs of a same‑origin web application while keeping operational complexity low.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.