Sitemap

10 tRPC Techniques for Frontend-First Teams

Ship features faster with end-to-end types, tight contracts, and zero swagger fatigue.

--

Press enter or click to view image in full size

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 valid

Net 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.

--

--

Nexumo
Nexumo

Written by Nexumo

Next-move engineering for builders - fast, clear, measurable. Systems, data, and AI you can ship today.