10 tRPC Techniques for Frontend-First Teams
Ship features faster with end-to-end types, tight contracts, and zero swagger fatigue.
Ten pragmatic tRPC techniques that speed up frontend-first development — contracts with Zod, batching, context, optimistic UX, and production-grade error handling.
Get Nexumo’s stories in your inbox
Join Medium for free to get updates from this writer.
You know the drill: designers ship, product nudges, and the frontend is already building the UI. The API? “Coming soon.”
tRPC lets you move anyway — without YOLOing types or hard-coding stubs. Below are ten techniques I rely on when a team leads with the frontend and wants real speed, not duct tape.
1) Treat Zod Schemas as Your Product Contracts
With tRPC, Zod isn’t “validation” — it’s the contract your product depends on. Put schemas in a shared @/contracts package so UI and server import the exact same types.
// packages/contracts/user.ts
import { z } from "zod";
export const UserId = z.string().uuid();
export const UserRole = z.enum(["user", "admin"]);
export const CreateUserInput = z.object({
email: z.string().email(),
name: z.string().min(1).max(80),
role: UserRole.default("user"),
});
export type CreateUserInput = z.infer<typeof CreateUserInput>;// server/routers/user.ts
import { CreateUserInput } from "@contracts/user";
import { publicProcedure, router } from "../trpc";
export const userRouter = router({
create: publicProcedure
.input(CreateUserInput)
.mutation(({ input, ctx }) => ctx.services.user.create(input)),
});Why it works: The UI consumes CreateUserInput for forms; the server enforces it. One change, one PR, fewer bugs.
2) Make Context a Real Boundary (Auth, Tenancy, Budgets)
Your createContext is where request reality enters your app: user, tenant, feature flags, rate budgets, and request-scoped services.
// server/context.ts
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
export async function createContext({ req }: CreateNextContextOptions) {
const session = await authFrom(req); // cookie/JWT
const tenant = session?.tenantId ?? "public";
return {
session,
tenant,
services: buildServices({ tenant, traceId: req.headers["x-trace-id"] }),
};
}Now every procedure can cleanly enforce rules:
import { TRPCError } from "@trpc/server";
export const authed = publicProcedure.use(async ({ ctx, next }) => {
if (!ctx.session) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, user: ctx.session.user } });
});3) Compose Routers Like Features, Not Layers
Break routers by business capability, not technical layers. Keep them small and discoverable.
// server/routers/index.ts
export const appRouter = router({
user: userRouter,
billing: billingRouter,
projects: projectsRouter,
});
export type AppRouter = typeof appRouter;Rule of thumb: If a router becomes a scroll-fest, split it. The caller API stays ergonomic: trpc.projects.list.useQuery().
4) Use Transformers to Avoid “Type Lies” (Dates, BigInt)
The moment you serialize a Date, you risk silent type drift in the UI. Use a transformer such as superjson so your types mean what they say.
// server/trpc.ts
import superjson from "superjson";
export const t = initTRPC.context<Context>().create({
transformer: superjson,
});Result: Date on the server is Date on the client—no parsing footguns.
5) Batching & Link Order: Free Latency Wins
tRPC’s httpBatchLink can bundle many queries into one HTTP request. Add a logger link in dev to see what’s actually happening.
// web/utils/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
export const trpc = createTRPCReact<AppRouter>();
export function getLinks() {
return [
...(process.env.NODE_ENV === "development"
? [loggerLink({ enabled: () => true })]
: []),
httpBatchLink({ url: "/api/trpc", maxURLLength: 2048 }),
];
}Pair batching with React Query’s staleTime to cut duplicate fetches on route changes.
6) Optimistic UX Without Regret (Invalidate Like an Adult)
Frontend-first teams live on fast feedback. Use optimistic updates, but also think in invalidation shapes so caches heal consistently.
// optimistic create
const utils = trpc.useUtils();
const createUser = trpc.user.create.useMutation({
onMutate: async (input) => {
await utils.user.list.cancel();
const prev = utils.user.list.getData();
utils.user.list.setData(undefined, (old) => [{ ...input, id: "temp" }, ...(old ?? [])]);
return { prev };
},
onError: (_e, _input, ctx) => ctx?.prev && utils.user.list.setData(undefined, ctx.prev),
onSettled: () => utils.user.list.invalidate(),
});Practice: Always invalidate by query, not by hope. Use utils.[router].[procedure].invalidate() in one place (custom hooks help).
7) SSG/SSR/ISR: Let Data Strategy Match the Page
tRPC plays nicely with Next.js hydration. Preload queries server-side to eliminate the “flash of spinner.”
// app/projects/page.tsx (Next.js App Router)
export default async function ProjectsPage() {
const ssg = await createProxySSGHelpers({ router: appRouter, ctx: await createContext(), transformer: superjson });
await ssg.projects.list.prefetch();
return (
<HydrateClient state={await ssg.dehydrate()}>
<ProjectsView />
</HydrateClient>
);
}When to use what:
- SSR for highly personalized dashboards.
- SSG/ISR for mostly static marketing or browse pages.
- Client-only when data is purely interactive and non-critical.
8) Error Shapes the UI Can Actually Work With
“400 Bad Request” helps nobody. Shape errors with codes and fields the UI understands.
// server/trpc.ts
export const t = initTRPC
.context<Context>()
.create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
fieldErrors: error.cause instanceof ZodError ? error.cause.flatten().fieldErrors : undefined,
},
};
},
});In the UI:
const { mutate } = trpc.user.create.useMutation({
onError: (err) => {
if (err.data?.fieldErrors?.email) setEmailError(err.data.fieldErrors.email.join(", "));
},
});Outcome: Forms show precise guidance; logs show exact causes; PMs stop filing “Validation unclear” tickets.
9) Realtime Without the Rewrite (Subscriptions & Live Queries)
Need live counters or streaming statuses? Use a wsLink for procedures that truly benefit from realtime, keep everything else on HTTP batching.
// client links with split transport
import { splitLink, wsLink } from "@trpc/client/links";
const ws = typeof window !== "undefined"
? wsLink<AppRouter>({ client: new WebSocket(`wss://${location.host}/ws`) })
: null;
const links = ws
? [
loggerLink({ enabled: () => process.env.NODE_ENV === "development" }),
splitLink({
condition: (op) => op.type === "subscription",
true: ws,
false: httpBatchLink({ url: "/api/trpc" }),
}),
]
: getLinks();Server side, expose a subscription for the few places that need it (job progress, notifications). Keep your router mostly query/mutation — simpler, cheaper.
10) Test the Contract, Not Just the Code
When the frontend depends on types, breakages must fail fast. Use createCaller for server-only integration tests and type-level tests for evolution safety.
// server/routers/user.spec.ts
import { appRouter } from "../routers";
import { createContext } from "../context";
it("creates a user", async () => {
const caller = appRouter.createCaller(await createContext(fakeReq()));
const user = await caller.user.create({ email: "a@b.com", name: "A" });
expect(user.id).toBeDefined();
});Type safety regression example (will fail to compile if contract changes incorrectly):
// type-test.ts (tsc in CI)
import type { CreateUserInput } from "@contracts/user";
const _: CreateUserInput = { email: "x@y.com", name: "x", role: "admin" }; // must still be validNet effect: You evolve the API without breaking the UI and spot contract drifts early.
A Lightweight Starter You Can Copy
// server/trpc.ts
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
import type { Context } from "./context";
export const t = initTRPC.context<Context>().create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const authedProcedure = t.procedure.use(({ ctx, next }) =>
ctx.session ? next() : next({ ctx, // throw in procedure if needed
}),
);// web/utils/trpc.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { trpc } from "./trpc-core";
export function Providers({ children }: { children: React.ReactNode }) {
const [qc] = React.useState(() => new QueryClient({
defaultOptions: { queries: { staleTime: 15_000 } },
}));
const client = trpc.createClient({ links: getLinks() });
return (
<trpc.Provider client={client} queryClient={qc}>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</trpc.Provider>
);
}Why this matters: It encodes the defaults — transformer, batching, cache tuning — so new features start performant and typed by default.
Wrap-Up
Frontend-first teams don’t have time for API paperwork. With tRPC, your UI and server share living contracts, not screenshots of Swagger. These ten techniques — contracts in Zod, real context boundaries, router composition, transformers, batching, optimistic UX, smart data strategies, helpful error shapes, selective realtime, and contract tests — give you speed and safety.
If you’ve got a favorite tRPC trick (or an edge case that burned you), drop it in the comments. I’m collecting patterns for a follow-up with deeper benchmarks and production war stories.









