tRPC
Build end-to-end type-safe APIs with tRPC.
The Pro Next.js Drizzle starter kit uses tRPC for its API layer, providing seamless type safety between your server-side logic and client-side components.
Define Endpoint
Protect Endpoint
Usage in Frontend
Architecture
Our tRPC setup is designed for performance and maintainability, with built-in support for authentication and organization-scoped data.
Root Router
The root router is located in trpc/routers/app.ts and aggregates all feature-specific routers using lazy loading:
import { createTRPCRouter } from '@/trpc/init';
import { lazy } from '@trpc/server';
export const appRouter = createTRPCRouter({
admin: lazy(() => import('./admin')),
organization: lazy(() => import('./organization')),
user: lazy(() => import('./user')),
upload: lazy(() => import('./upload')),
contact: lazy(() => import('./contact'))
});
export type AppRouter = typeof appRouter;Lazy Loading Routers are lazy-loaded to improve initial bundle size and enable code splitting.
Procedures
We provide several base procedures to simplify development:
publicProcedure: No authentication requiredprotectedProcedure: Requires a valid user sessionprotectedOrganizationProcedure: Requires a valid session and an active organization
Example: Public Endpoint
import { createTRPCRouter, publicProcedure } from '@/trpc/init';
import { z } from 'zod';
export const publicRouter = createTRPCRouter({
health: publicProcedure.query(() => {
return { status: 'ok', timestamp: new Date() };
})
});Example: Protected Endpoint
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { z } from 'zod';
export const userRouter = createTRPCRouter({
getProfile: protectedProcedure.query(async ({ ctx }) => {
// ctx.user is guaranteed to exist
return ctx.user;
}),
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).optional(),
email: z.string().email().optional()
})
)
.mutation(async ({ input, ctx }) => {
// Update user profile
return await updateUser(ctx.user.id, input);
})
});Example: Organization-Scoped Endpoint
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '@/lib/db';
import { leadTable } from '@/lib/db/schema';
export const organizationRouter = createTRPCRouter({
getLeads: protectedOrganizationProcedure.query(async ({ ctx }) => {
// ctx.organization is guaranteed to exist
return await db.query.leadTable.findMany({
where: eq(leadTable.organizationId, ctx.organization.id)
});
}),
createLead: protectedOrganizationProcedure
.input(
z.object({
name: z.string().min(1),
email: z.string().email()
})
)
.mutation(async ({ input, ctx }) => {
const [lead] = await db
.insert(leadTable)
.values({
...input,
organizationId: ctx.organization.id
})
.returning();
return lead;
})
});Client Usage
React Hooks
On the client, use the trpc object to access your API procedures via React Query hooks.
'use client';
import { trpc } from '@/trpc/client';
export function UserProfile() {
const { data: user, isLoading } = trpc.user.getProfile.useQuery();
if (isLoading) return <div>Loading...</div>;
if (!user) return <div>Not found</div>;
return <div>Hello, {user.name}!</div>;
}Mutations
For actions that modify data, use mutations.
'use client';
import { trpc } from '@/trpc/client';
import { useForm } from 'react-hook-form';
export function UpdateProfileForm() {
const utils = trpc.useUtils();
const updateProfile = trpc.user.updateProfile.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.user.getProfile.invalidate();
}
});
const onSubmit = async (data: { name: string }) => {
await updateProfile.mutateAsync(data);
};
return <form onSubmit={handleSubmit(onSubmit)}>{/* form fields */}</form>;
}Optimistic Updates
For better UX, use optimistic updates:
'use client';
import { trpc } from '@/trpc/client';
export function OptimisticComponent() {
const utils = trpc.useUtils();
const updateMutation = trpc.user.updateProfile.useMutation({
onMutate: async (newData) => {
// Cancel outgoing refetches
await utils.user.getProfile.cancel();
// Snapshot previous value
const previous = utils.user.getProfile.getData();
// Optimistically update
utils.user.getProfile.setData(undefined, (old) => ({
...old!,
...newData
}));
return { previous };
},
onError: (err, newData, context) => {
// Rollback on error
utils.user.getProfile.setData(undefined, context?.previous);
},
onSettled: () => {
// Refetch to ensure consistency
utils.user.getProfile.invalidate();
}
});
// ... use mutation
}Server-Side Usage
Prefetching
For better performance, prefetch data on the server in your Next.js Server Components.
import { HydrateClient, trpc } from '@/trpc/server';
export default async function ProfilePage() {
// Prefetch data on the server
await trpc.user.getProfile.prefetch();
return (
<HydrateClient>
<UserProfile />
</HydrateClient>
);
}Server-Side Calls
You can also call tRPC procedures directly on the server:
import { trpc } from '@/trpc/server';
export async function GET() {
const user = await trpc.user.getProfile();
return Response.json(user);
}Organization Scoping
The starter kit includes organization scoping to automatically filter data by organization:
// Organization is automatically determined from the request context
// and made available in ctx.organizationWhen using protectedOrganizationProcedure, the organization is automatically determined from:
- The active organization ID stored in the session (
session.activeOrganizationId) - The active organization in the session
- Custom organization resolution logic
Type Inference
Extract types from your procedures for use in your components.
import type { AppRouter } from '@/trpc/routers/app';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
type RouterOutputs = inferRouterOutputs<AppRouter>;
type RouterInputs = inferRouterInputs<AppRouter>;
// Extract output type
export type Lead = RouterOutputs['organization']['getLeads'][number];
// Extract input type
export type CreateLeadInput = RouterInputs['organization']['createLead'];Error Handling
tRPC automatically handles errors and provides type-safe error handling:
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
export const exampleRouter = createTRPCRouter({
getData: protectedProcedure.query(async ({ ctx }) => {
const data = await fetchData();
if (!data) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Data not found'
});
}
return data;
})
});'use client';
import { trpc } from '@/trpc/client';
export function DataComponent() {
const { data, error, isLoading } = trpc.example.getData.useQuery();
if (error) {
if (error.data?.code === 'NOT_FOUND') {
return <div>Data not found</div>;
}
return <div>Error: {error.message}</div>;
}
if (isLoading) return <div>Loading...</div>;
return <div>{data}</div>;
}Best Practices
- Use appropriate procedures - Choose
publicProcedure,protectedProcedureorprotectedOrganizationProcedurebased on your needs - Validate inputs - Always use Zod schemas for input validation
- Handle errors - Use
TRPCErrorfor consistent error handling - Optimize queries - Use prefetching and optimistic updates for better UX
- Type safety - Leverage TypeScript inference for type safety