General

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.

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:

trpc/routers/app.ts
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;

Procedures

We provide several base procedures to simplify development:

  • publicProcedure: No authentication required
  • protectedProcedure: Requires a valid user session
  • protectedOrganizationProcedure: Requires a valid session and an active organization

Example: Public Endpoint

trpc/routers/public.ts
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

trpc/routers/user.ts
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

trpc/routers/organization.ts
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.

components/user-profile.tsx
'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.

components/update-profile-form.tsx
'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:

components/optimistic-update.tsx
'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.

app/(saas)/dashboard/profile/page.tsx
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:

app/api/example/route.ts
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:

trpc/organization-scope.ts
// Organization is automatically determined from the request context
// and made available in ctx.organization

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

types/lead.ts
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:

trpc/routers/example.ts
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;
  })
});
components/error-handling.tsx
'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

  1. Use appropriate procedures - Choose publicProcedure, protectedProcedure or protectedOrganizationProcedure based on your needs
  2. Validate inputs - Always use Zod schemas for input validation
  3. Handle errors - Use TRPCError for consistent error handling
  4. Optimize queries - Use prefetching and optimistic updates for better UX
  5. Type safety - Leverage TypeScript inference for type safety