tRPC API Reference
The TrendGate platform uses tRPC for type-safe API communication between the client and server. This provides end-to-end type safety and excellent developer experience.
🚀 Overview
tRPC enables you to build fully typesafe APIs without schemas or code generation. It shares types between client and server automatically.
Key Benefits
- End-to-end type safety - Share types between client and server
- No code generation - Types are inferred automatically
- RPC-like client - Call server functions like regular functions
- Framework agnostic - Works with any frontend framework
- Lightweight - Small bundle size with zero dependencies
🔧 Setup
Server Setup
// server/api/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
import { getServerAuthSession } from "@/server/auth";
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
Client Setup
// utils/api.ts
import { httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import type { AppRouter } from "@/server/api/root";
export const api = createTRPCNext<AppRouter>({
config() {
return {
transformer: superjson,
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
};
},
ssr: false,
});
📋 Router Structure
Our tRPC API is organized into logical routers:
// server/api/root.ts
export const appRouter = createTRPCRouter({
auth: authRouter,
session: sessionRouter,
instructor: instructorRouter,
member: memberRouter,
booking: bookingRouter,
payment: paymentRouter,
notification: notificationRouter,
});
export type AppRouter = typeof appRouter;
🔐 Authentication
Protected procedures require authentication:
const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
...ctx,
session: { ...ctx.session, user: ctx.session.user },
},
});
});
📚 API Procedures
Session Router
export const sessionRouter = createTRPCRouter({
// List sessions with filtering
list: protectedProcedure
.input(
z.object({
instructorId: z.string().optional(),
memberId: z.string().optional(),
status: z.enum(["scheduled", "completed", "cancelled"]).optional(),
dateFrom: z.date().optional(),
dateTo: z.date().optional(),
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const sessions = await ctx.db.session.findMany({
where: {
instructorId: input.instructorId,
memberId: input.memberId,
status: input.status,
date: {
gte: input.dateFrom,
lte: input.dateTo,
},
},
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
include: {
instructor: true,
member: true,
pool: true,
},
orderBy: { date: "asc" },
});
let nextCursor: typeof input.cursor | undefined = undefined;
if (sessions.length > input.limit) {
const nextItem = sessions.pop();
nextCursor = nextItem!.id;
}
return {
sessions,
nextCursor,
};
}),
// Get single session
getById: protectedProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const session = await ctx.db.session.findUnique({
where: { id: input.id },
include: {
instructor: {
include: {
certifications: true,
},
},
member: true,
pool: true,
notes: {
orderBy: { createdAt: "desc" },
},
},
});
if (!session) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Session not found",
});
}
return session;
}),
// Create session
create: protectedProcedure
.input(
z.object({
instructorId: z.string(),
memberId: z.string(),
date: z.date(),
durationMinutes: z.number().min(15).max(180),
poolId: z.string(),
notes: z.string().optional(),
recurring: z
.object({
frequency: z.enum(["weekly", "biweekly"]),
count: z.number().min(1).max(52),
})
.optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Check instructor availability
const isAvailable = await checkInstructorAvailability(
input.instructorId,
input.date,
input.durationMinutes
);
if (!isAvailable) {
throw new TRPCError({
code: "CONFLICT",
message: "Instructor is not available at this time",
});
}
// Create session(s)
if (input.recurring) {
return createRecurringSessions(ctx.db, input);
}
return ctx.db.session.create({
data: {
instructorId: input.instructorId,
memberId: input.memberId,
date: input.date,
durationMinutes: input.durationMinutes,
poolId: input.poolId,
notes: input.notes,
status: "scheduled",
},
});
}),
// Update session
update: protectedProcedure
.input(
z.object({
id: z.string(),
date: z.date().optional(),
durationMinutes: z.number().min(15).max(180).optional(),
notes: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return ctx.db.session.update({
where: { id },
data,
});
}),
// Cancel session
cancel: protectedProcedure
.input(
z.object({
id: z.string(),
reason: z.string(),
notifyParticipants: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
const session = await ctx.db.session.update({
where: { id: input.id },
data: {
status: "cancelled",
cancelledAt: new Date(),
cancelReason: input.reason,
},
include: {
instructor: true,
member: true,
},
});
if (input.notifyParticipants) {
await sendCancellationNotifications(session);
}
return session;
}),
// Real-time session updates
onUpdate: protectedProcedure
.input(z.object({ sessionId: z.string() }))
.subscription(({ ctx, input }) => {
return observable<Session>((emit) => {
const unsubscribe = subscribeToSession(input.sessionId, (session) => {
emit.next(session);
});
return () => {
unsubscribe();
};
});
}),
});
Instructor Router
export const instructorRouter = createTRPCRouter({
// Get instructor profile
getProfile: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
return ctx.db.instructor.findUnique({
where: { id: input.id },
include: {
user: {
select: {
name: true,
image: true,
},
},
certifications: true,
serviceAreas: true,
availability: true,
reviews: {
take: 5,
orderBy: { createdAt: "desc" },
include: {
reviewer: {
select: { name: true, image: true },
},
},
},
_count: {
select: {
sessions: true,
reviews: true,
},
},
},
});
}),
// Search instructors
search: publicProcedure
.input(
z.object({
query: z.string().optional(),
certifications: z.array(z.string()).optional(),
serviceAreaId: z.string().optional(),
availableDate: z.date().optional(),
priceRange: z
.object({
min: z.number(),
max: z.number(),
})
.optional(),
limit: z.number().default(20),
cursor: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
// Complex search implementation
return searchInstructors(ctx.db, input);
}),
// Update availability
updateAvailability: protectedProcedure
.input(
z.object({
schedule: z.array(
z.object({
dayOfWeek: z.number().min(0).max(6),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
})
),
exceptions: z
.array(
z.object({
date: z.date(),
available: z.boolean(),
startTime: z.string().optional(),
endTime: z.string().optional(),
})
)
.optional(),
})
)
.mutation(async ({ ctx, input }) => {
const instructorId = ctx.session.user.instructorId;
if (!instructorId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User is not an instructor",
});
}
return updateInstructorAvailability(ctx.db, instructorId, input);
}),
// Get earnings
getEarnings: protectedProcedure
.input(
z.object({
dateFrom: z.date(),
dateTo: z.date(),
groupBy: z.enum(["day", "week", "month"]).default("month"),
})
)
.query(async ({ ctx, input }) => {
const instructorId = ctx.session.user.instructorId;
return calculateInstructorEarnings(ctx.db, instructorId, input);
}),
});
Member Router
export const memberRouter = createTRPCRouter({
// List account members
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.member.findMany({
where: {
accountId: ctx.session.user.accountId,
},
include: {
progress: {
orderBy: { createdAt: "desc" },
take: 1,
},
},
});
}),
// Create member
create: protectedProcedure
.input(
z.object({
name: z.string(),
dateOfBirth: z.date(),
swimmingLevel: z.enum(["beginner", "intermediate", "advanced"]),
medicalNotes: z.string().optional(),
emergencyContact: z.object({
name: z.string(),
phone: z.string(),
relationship: z.string(),
}),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.db.member.create({
data: {
...input,
accountId: ctx.session.user.accountId,
},
});
}),
// Get progress
getProgress: protectedProcedure
.input(
z.object({
memberId: z.string(),
limit: z.number().default(10),
})
)
.query(async ({ ctx, input }) => {
const member = await ctx.db.member.findFirst({
where: {
id: input.memberId,
accountId: ctx.session.user.accountId,
},
});
if (!member) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Member not found",
});
}
return ctx.db.progress.findMany({
where: { memberId: input.memberId },
orderBy: { createdAt: "desc" },
take: input.limit,
include: {
skills: true,
instructor: {
select: { name: true },
},
},
});
}),
});
Booking Router
export const bookingRouter = createTRPCRouter({
// Create booking
create: protectedProcedure
.input(
z.object({
instructorId: z.string(),
memberIds: z.array(z.string()),
sessions: z.array(
z.object({
date: z.date(),
durationMinutes: z.number(),
})
),
paymentMethodId: z.string(),
promoCode: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Validate availability for all sessions
const availability = await checkBulkAvailability(input.instructorId, input.sessions);
if (!availability.allAvailable) {
throw new TRPCError({
code: "CONFLICT",
message: "Some sessions are not available",
cause: availability.conflicts,
});
}
// Calculate pricing
const pricing = await calculateBookingPrice(input);
// Create booking with sessions
const booking = await ctx.db.$transaction(async (tx) => {
const booking = await tx.booking.create({
data: {
accountId: ctx.session.user.accountId,
instructorId: input.instructorId,
totalAmount: pricing.total,
status: "pending",
sessions: {
create: input.sessions.map((session, index) => ({
...session,
memberId: input.memberIds[index % input.memberIds.length],
status: "scheduled",
})),
},
},
include: {
sessions: true,
},
});
// Process payment
const payment = await processPayment({
bookingId: booking.id,
amount: pricing.total,
paymentMethodId: input.paymentMethodId,
});
if (payment.status === "succeeded") {
await tx.booking.update({
where: { id: booking.id },
data: { status: "confirmed" },
});
}
return booking;
});
// Send confirmation
await sendBookingConfirmation(booking);
return booking;
}),
});
🎯 Usage Examples
React Component
import { api } from "@/utils/api";
export function SessionList() {
const { data, isLoading, error } = api.session.list.useQuery({
status: "scheduled",
limit: 10,
});
const cancelMutation = api.session.cancel.useMutation({
onSuccess: () => {
// Invalidate and refetch
api.session.list.invalidate();
},
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<div>
{data?.sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onCancel={() =>
cancelMutation.mutate({
id: session.id,
reason: "Scheduling conflict",
})
}
/>
))}
</div>
);
}
Server Component
import { api } from "@/trpc/server";
export async function InstructorProfile({ id }: { id: string }) {
const instructor = await api.instructor.getProfile.query({ id });
if (!instructor) {
notFound();
}
return (
<div>
<h1>{instructor.user.name}</h1>
<Rating value={instructor.rating} count={instructor._count.reviews} />
{/* ... */}
</div>
);
}
Optimistic Updates
function QuickBooking() {
const utils = api.useContext();
const createBooking = api.booking.create.useMutation({
onMutate: async (newBooking) => {
// Cancel outgoing refetches
await utils.session.list.cancel();
// Snapshot previous value
const previousSessions = utils.session.list.getData();
// Optimistically update
utils.session.list.setData({ status: "scheduled" }, (old) => ({
...old,
sessions: [...(old?.sessions ?? []), ...newBooking.sessions],
}));
return { previousSessions };
},
onError: (err, newBooking, context) => {
// Rollback on error
utils.session.list.setData({ status: "scheduled" }, context?.previousSessions);
},
onSettled: () => {
// Sync with server
utils.session.list.invalidate();
},
});
return <BookingForm onSubmit={createBooking.mutate} />;
}
Real-time Subscriptions
function LiveSession({ sessionId }: { sessionId: string }) {
const [session, setSession] = useState<Session>();
api.session.onUpdate.useSubscription(
{ sessionId },
{
onData: (data) => {
setSession(data);
},
}
);
return <SessionDisplay session={session} />;
}
🛠️ Advanced Features
Input Validation
const emailSchema = z.string().email();
const phoneSchema = z.string().regex(/^\+?[1-9]\d{1,14}$/);
const updateContactInfo = protectedProcedure
.input(
z.object({
email: emailSchema.optional(),
phone: phoneSchema.optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Input is fully validated and typed
});
Error Handling
try {
await api.session.create.mutate(data);
} catch (error) {
if (error instanceof TRPCClientError) {
const code = error.data?.code;
switch (code) {
case "CONFLICT":
toast.error("This time slot is no longer available");
break;
case "UNAUTHORIZED":
router.push("/login");
break;
default:
toast.error("Something went wrong");
}
}
}
Middleware
const rateLimitMiddleware = t.middleware(async ({ ctx, next, meta }) => {
const identifier = ctx.session?.user.id ?? ctx.ip;
const { success } = await ratelimit.limit(identifier);
if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Rate limit exceeded",
});
}
return next();
});
export const rateLimitedProcedure = t.procedure.use(rateLimitMiddleware);