Building full-stack applications with TypeScript has revolutionized how we approach type safety, but [API](/workers) boundaries have traditionally remained a weak point where type information gets lost. Enter tRPC—a library that extends TypeScript's type safety across your entire application stack, eliminating the gap between client and server code while maintaining the flexibility of modern API design.
At PropTechUSA.ai, we've leveraged tRPC extensively in our [property](/offer-check) technology solutions to ensure robust communication between our React frontends and Node.js backends, particularly when handling complex property data structures and real-time market [analytics](/dashboards).
Understanding tRPC's Revolutionary Approach to API Design
The Traditional API Problem
Traditional REST and GraphQL APIs require developers to manually maintain type definitions on both client and server sides. This dual maintenance creates opportunities for inconsistencies, runtime errors, and deployment issues that only surface in production.
Consider a typical property listing API endpoint:
// Server-side type
interface PropertyListing {
id: string;
address: string;
price: number;
bedrooms: number;
bathrooms: number;
listingDate: Date;
}
// Client-side type (manually maintained)
interface ClientPropertyListing {
id: string;
address: string;
price: number;
bedrooms: number;
bathrooms: number;
listingDate: string; // Oops! Date serialization issue
}
This disconnect leads to runtime failures and debugging nightmares.
How tRPC Solves Type Safety
tRPC eliminates this problem by generating client types directly from server implementations. The server becomes the single source of truth for all type information, automatically propagated to clients during development.
// Server-side procedure definition
const propertyRouter = router({
getProperty: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.property.findUnique({
where: { id: input.id }
});
})
});
// Client automatically gets full type safety
const property = await trpc.getProperty.query({ id: "prop-123" });
// property is fully typed with no manual intervention
The Full-Stack TypeScript Advantage
When implementing tRPC in full-stack TypeScript applications, developers gain end-to-end type safety that extends beyond simple data fetching. Complex business logic, validation rules, and data transformations maintain their type contracts across the entire application boundary.
Core Concepts and Architecture Patterns
Procedures and Routers
tRPC organizes API endpoints as "procedures" grouped within "routers." Procedures come in three varieties: queries for data fetching, mutations for state changes, and subscriptions for real-time updates.
import { router, publicProcedure } from './trpc';
import { z } from 'zod';
const propertyRouter = router({
// Query procedure
searchProperties: publicProcedure
.input(z.object({
location: z.string(),
priceRange: z.object({
min: z.number(),
max: z.number()
}),
propertyType: z.enum(['house', 'condo', 'apartment'])
}))
.query(async ({ input }) => {
return await searchPropertiesInDatabase(input);
}),
// Mutation procedure
createListing: publicProcedure
.input(z.object({
address: z.string().min(5),
price: z.number().positive(),
description: z.string().max(1000)
}))
.mutation(async ({ input }) => {
return await createPropertyListing(input);
}),
// Subscription procedure
priceUpdates: publicProcedure
.input(z.object({ propertyId: z.string() }))
.subscription(async function* ({ input }) {
// Yield price updates as they occur
for await (const update of getPriceUpdateStream(input.propertyId)) {
yield update;
}
})
});
Input Validation with Zod Integration
tRPC's tight integration with Zod provides runtime validation that doubles as compile-time type information. This integration ensures that invalid data never reaches your business logic while maintaining TypeScript's static analysis benefits.
const createPropertySchema = z.object({
address: z.string()
.min(10, "Address too short")
.max(200, "Address too long"),
price: z.number()
.positive("Price must be positive")
.max(50000000, "Price exceeds maximum"),
coordinates: z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180)
}),
amenities: z.array(z.string()).optional(),
availableFrom: z.date().min(new Date(), "Date must be in future")
});
const createProperty = publicProcedure
.input(createPropertySchema)
.mutation(async ({ input }) => {
// input is fully typed and validated
return await propertyService.create(input);
});
Context and Middleware
tRPC's context system provides dependency injection and request-scoped data access. Middleware enables cross-cutting concerns like authentication, logging, and performance monitoring.
// Context creation
const createContext = async ({ req, res }: CreateContextOptions) => {
const user = await getUserFromToken(req.headers.authorization);
return {
user,
db: prismaClient,
logger: createLogger({ requestId: generateId() })
};
};
// Authentication middleware
const authMiddleware = middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
// Protected procedure
const protectedProcedure = publicProcedure.use(authMiddleware);
Implementation Guide and Real-World Examples
Server Setup and Configuration
Setting up a tRPC server involves defining your router structure, configuring context creation, and integrating with your chosen HTTP framework.
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { Context } from './context';
const t = initTRPC.context<Context>().create({
transformer: superjson, // Handles Date, Map, Set serialization
errorFormatter: ({ shape, error }) => {
return {
...shape,
data: {
...shape.data,
zodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
? error.cause.flatten()
: null
}
};
}
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
// server/routers/app.ts
import { router } from '../trpc';
import { propertyRouter } from './property';
import { userRouter } from './user';
import { analyticsRouter } from './analytics';
export const appRouter = router({
property: propertyRouter,
user: userRouter,
analytics: analyticsRouter
});
export type AppRouter = typeof appRouter;
Client Configuration and Usage
Client setup involves creating a typed client instance and configuring it with your preferred HTTP client and caching strategy.
// client/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/routers/app';
export const trpc = createTRPCReact<AppRouter>();
// Client configuration
export const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
headers: async () => {
const token = await getAuthToken();
return token ? { authorization: Bearer ${token} } : {};
}
})
],
transformer: superjson
});
// components/PropertySearch.tsx
import { trpc } from '../utils/trpc';
export function PropertySearch() {
const [searchParams, setSearchParams] = useState({
location: '',
priceRange: { min: 0, max: 1000000 },
propertyType: 'house' as const
});
const { data: properties, isLoading, error } = trpc.property.searchProperties.useQuery(
searchParams,
{ enabled: searchParams.location.length > 0 }
);
const createListingMutation = trpc.property.createListing.useMutation({
onSuccess: () => {
// Invalidate and refetch properties
trpc.useContext().property.searchProperties.invalidate();
}
});
// Component implementation...
}
Advanced Patterns and Optimizations
For complex applications, tRPC supports advanced patterns like request batching, response caching, and conditional queries.
// Batch multiple queries
const [properties, user, analytics] = await Promise.all([
trpc.property.searchProperties.query(searchParams),
trpc.user.getProfile.query(),
trpc.analytics.getMarketData.query({ location: 'San Francisco' })
]);
// Infinite queries for pagination
const {
data,
fetchNextPage,
hasNextPage,
isLoading
} = trpc.property.getPropertiesPaginated.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor
}
);
// Optimistic updates
const utils = trpc.useContext();
const updatePropertyMutation = trpc.property.updateListing.useMutation({
onMutate: async (newData) => {
await utils.property.getProperty.cancel({ id: newData.id });
const previousData = utils.property.getProperty.getData({ id: newData.id });
utils.property.getProperty.setData(
{ id: newData.id },
(old) => ({ ...old, ...newData })
);
return { previousData };
},
onError: (err, newData, context) => {
utils.property.getProperty.setData(
{ id: newData.id },
context?.previousData
);
}
});
Best Practices and Production Considerations
Error Handling Strategies
Proper error handling in tRPC applications involves both server-side error formatting and client-side error boundary implementation.
// Server-side error handling
const getPropertyProcedure = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
try {
const property = await ctx.db.property.findUnique({
where: { id: input.id }
});
if (!property) {
throw new TRPCError({
code: 'NOT_FOUND',
message: Property with ID ${input.id} not found
});
}
return property;
} catch (error) {
ctx.logger.error('Failed to fetch property', { error, id: input.id });
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to retrieve property data'
});
}
});
// Client-side error handling
function PropertyDetails({ id }: { id: string }) {
const { data, error, isLoading } = trpc.property.getProperty.useQuery(
{ id },
{
retry: (failureCount, error) => {
// Don't retry on 404s
if (error.data?.code === 'NOT_FOUND') return false;
return failureCount < 3;
},
onError: (error) => {
toast.error(Failed to load property: ${error.message});
}
}
);
if (isLoading) return <PropertySkeleton />;
if (error?.data?.code === 'NOT_FOUND') return <PropertyNotFound />;
if (error) return <ErrorBoundary error={error} />;
return <PropertyCard property={data} />;
}
Performance Optimization
Optimizing tRPC applications involves strategic use of caching, prefetching, and subscription management.
// Prefetch critical data
useEffect(() => {
// Prefetch property details when hovering over search results
const prefetchProperty = (id: string) => {
trpc.property.getProperty.prefetch({ id });
};
// Prefetch user's saved properties
if (user) {
trpc.user.getSavedProperties.prefetch();
}
}, [user]);
// Strategic cache invalidation
const createPropertyMutation = trpc.property.createListing.useMutation({
onSuccess: (newProperty) => {
// Update search results cache
utils.property.searchProperties.setData(
searchParams,
(oldData) => oldData ? [newProperty, ...oldData] : [newProperty]
);
// Invalidate related queries
utils.user.getUserListings.invalidate();
utils.analytics.getMarketData.invalidate({ location: newProperty.city });
}
});
Type Safety Best Practices
Maintaining type safety across large tRPC applications requires disciplined schema design and validation strategies.
// Shared schema definitions
export const propertySchemas = {
base: z.object({
id: z.string().uuid(),
address: z.string().min(5).max(200),
price: z.number().positive(),
createdAt: z.date(),
updatedAt: z.date()
}),
create: z.object({
address: z.string().min(5).max(200),
price: z.number().positive(),
description: z.string().max(2000).optional(),
amenities: z.array(z.enum(['parking', 'gym', 'pool', 'balcony'])).optional()
}),
update: z.object({
id: z.string().uuid(),
price: z.number().positive().optional(),
description: z.string().max(2000).optional(),
amenities: z.array(z.enum(['parking', 'gym', 'pool', 'balcony'])).optional()
}).strict() // Prevent extra properties
};
// Type extraction for use in components
export type Property = z.infer<typeof propertySchemas.base>;
export type CreatePropertyInput = z.infer<typeof propertySchemas.create>;
export type UpdatePropertyInput = z.infer<typeof propertySchemas.update>;
Scaling tRPC in Enterprise Applications
As your application grows, tRPC's type-safe approach becomes even more valuable. At PropTechUSA.ai, we've successfully scaled tRPC implementations to handle thousands of concurrent property searches while maintaining sub-100ms response times through strategic caching and query optimization.
The key to successful tRPC adoption lies in embracing its opinionated approach to full-stack TypeScript development. By treating your server as the authoritative source of type information, you eliminate entire categories of integration bugs while accelerating development velocity.
Consider implementing tRPC in your next full-stack TypeScript project, starting with a small feature area and gradually expanding coverage. The initial investment in learning tRPC's patterns pays dividends through reduced debugging time, improved developer confidence, and more robust production deployments.
Ready to implement type-safe APIs in your property technology stack? Explore how PropTechUSA.ai can help you leverage tRPC and other cutting-edge technologies to build scalable, maintainable real estate applications that deliver exceptional user experiences.