import { initTRPC, TRPCError } from '@trpc/server';
export interface Context {
user: { id: string; role: 'user' | 'admin' } | null;
db: DatabaseClient;
}
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Sign in required' });
}
return next({ ctx: { user: ctx.user } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { router, publicProcedure, protectedProcedure } from './trpc';
export const postRouter = router({
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({ where: { id: input.id } });
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found' });
}
return post;
}),
list: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().nullish(),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.post.findMany({
take: input.limit,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
}),
create: protectedProcedure
.input(z.object({ title: z.string().min(1).max(200), body: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: { ...input, authorId: ctx.user.id },
});
}),
});
import { router } from './trpc';
import { postRouter } from './postRouter';
export const appRouter = router({
post: postRouter,
});
export type AppRouter = typeof appRouter;
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/appRouter';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
const token = localStorage.getItem('token');
return token ? { authorization: `Bearer ${token}` } : {};
},
}),
],
});
export async function loadFeed() {
const posts = await trpc.post.list.query({ limit: 10 });
const created = await trpc.post.create.mutate({
title: 'Type-safe by default',
body: 'No codegen needed.',
});
return { posts, created };
}
This snippet shows the core of a tRPC setup: how a server-side router is defined, how procedures are protected with middleware, and how a client calls them with full end-to-end type inference and no code generation. The value of tRPC is that the input and output types flow from the server definitions straight into the client, so a rename or a schema change surfaces as a compile error at every call site.
In trpc.ts, the builder is initialized once with initTRPC.context<Context>().create(). The generic Context type — carrying an optional user and a db handle — is what every procedure sees at runtime. From this instance the module re-exports router, publicProcedure, and a reusable protectedProcedure. The isAuthed middleware inspects ctx.user; if it is missing it throws a TRPCError with code: 'UNAUTHORIZED', otherwise it calls next({ ctx: { user: ctx.user } }). That narrowing is the important part: after the middleware, ctx.user is typed as non-null downstream, so protected resolvers never need to re-check for null.
In postRouter.ts, each procedure attaches a Zod schema via .input(...). Zod does double duty here — it validates the request at runtime and, because tRPC reads its inferred type, it also types the input argument in the resolver. create is a protectedProcedure.mutation, so it can safely read ctx.user.id. byId is a publicProcedure.query that throws NOT_FOUND when the row is missing. The list query uses .default(...) values so the client may omit them entirely, demonstrating how optional inputs stay type-safe.
The routers compose in appRouter.ts through router({ post: postRouter }), and the exported AppRouter type is the single artifact the client imports — a type, never runtime code, so nothing server-side ships to the browser.
In client.ts, createTRPCProxyClient<AppRouter>() produces a proxy whose method paths mirror the router tree. Calls like trpc.post.byId.query(...) are fully autocompleted and checked; passing a wrong field is a build error. This pattern suits fullstack TypeScript monorepos where server and client share a type boundary, trading framework lock-in for eliminating an entire class of API drift bugs.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
Share this code
Here's the card — post it anywhere.