LogoBoring Template

tRPC

Type-safe API calls with end-to-end TypeScript safety using tRPC and React Query.

This guide explains how to use the tRPC integration for type-safe API calls in the application, including available routes, custom hooks, and best practices. This template uses tRPC with react-query under the hood. For more information, visit the tRPC documentation.

Overview

tRPC enables end-to-end typesafe APIs, ensuring that your frontend and backend are always in sync without schema validation or code generation. Our application implements a structured tRPC router system with dedicated hooks for common operations.

Key Benefits

Full Type Safety

End-to-end type safety between client and server

Auto Documentation

Automatic API documentation through TypeScript

Error Handling

Simplified error handling with proper typing

React Query

Integrated with React Query for caching and state management

Zero Config

Zero configuration for most use cases

Available Routes

The application organizes tRPC routes into logical groups by feature. Below are the available routers defined in _app.ts.

trpc.users.*

User management operations

trpc.items.*

CRUD operations for items

trpc.notifications.*

User notification management

trpc.workspaces.*

Workspace operations

trpc.members.*

Workspace member management

trpc.invitations.*

Invitation creation and handling

trpc.usersSettings.*

User settings management

trpc.subscriptions.*

Subscription management

trpc.feedbacks.*

User feedback submission

Router Configuration

trpc/routers/_app.ts
import { createTRPCRouter } from "@/trpc/init";
import { feedbacksRouter } from "@/trpc/routers/feedbacks";
import { invitationsRouter } from "@/trpc/routers/invitations";
import { itemsRouter } from "@/trpc/routers/items";
import { membersRouter } from "@/trpc/routers/members";
import { notificationsRouter } from "@/trpc/routers/notifications";
import { subscriptionsRouter } from "@/trpc/routers/subscriptions";
import { usersRouter } from "@/trpc/routers/users";
import { usersSettingsRouter } from "@/trpc/routers/users-settings";
import { workspacesRouter } from "@/trpc/routers/workspaces";
 
export const appRouter = createTRPCRouter({
  users: usersRouter,
  items: itemsRouter,
  notifications: notificationsRouter,
  workspaces: workspacesRouter,
  members: membersRouter,
  invitations: invitationsRouter,
  usersSettings: usersSettingsRouter,
  subscriptions: subscriptionsRouter,
  feedbacks: feedbacksRouter,
});
 
// export type definition of API
export type AppRouter = typeof appRouter;

Custom Hooks

Each router has dedicated hooks organized in files like items-hooks.ts or users-hooks.ts. These hooks encapsulate common functionality and provide built-in error handling, toast notifications, and cache invalidation.

Item Hooks Example

Custom hooks for item operations defined in items-hooks.ts:

trpc/hooks/items-hooks.ts
import { trpc } from "@/trpc/client";
import { toast } from "sonner";
 
import { GLOBAL_ERROR_MESSAGE } from "@/lib/constants";
import { useNewItemModal } from "@/hooks/use-new-item-modal";
 
export const useCreateItemTRPC = ({
  onSuccess,
}: {
  onSuccess?: () => void;
}) => {
  const utils = trpc.useUtils();
  const { close } = useNewItemModal();
 
  const { mutate, isPending } = trpc.items.create.useMutation({
    onSuccess: (res) => {
      toast.success(res.message);
      onSuccess?.();
      close();
      utils.items.getMany.invalidate();
    },
    onError: (error) => {
      toast.error(error.message || GLOBAL_ERROR_MESSAGE);
    },
  });
 
  return { mutate, isPending };
};
 
export const useDeleteItemTRPC = () => {
  const utils = trpc.useUtils();
 
  const { mutate, isPending } = trpc.items.delete.useMutation({
    onSuccess: (res) => {
      toast.success(res.message);
      utils.items.getMany.invalidate();
    },
    onError: (error) => {
      toast.error(error.message || GLOBAL_ERROR_MESSAGE);
    },
  });
 
  return { mutate, isPending };
};
 
export const useDuplicateItemTRPC = () => {
  const utils = trpc.useUtils();
 
  const { mutate, isPending } = trpc.items.duplicate.useMutation({
    onSuccess: (res) => {
      toast.success(res.message);
      utils.items.getMany.invalidate();
    },
    onError: (error) => {
      toast.error(error.message || GLOBAL_ERROR_MESSAGE);
    },
  });
 
  return { mutate, isPending };
};

Using tRPC Hooks

Using Mutation Hooks

Example of using a tRPC mutation hook in a component:

components/items/create-item-form.tsx
import { useForm } from "react-hook-form";
import { useCreateItemTRPC } from "@/hooks/items-hooks";
 
export function CreateItemForm() {
  const form = useForm();
 
  const { mutate, isPending } = useCreateItemTRPC({
    onSuccess: () => {
      form.reset();
    },
  });
 
  const onSubmit = (data) => {
    mutate({
      slug: "workspace-slug",
      values: data,
    });
  };
 
  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* Form fields */}
      <Button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create Item"}
      </Button>
    </form>
  );
}

Using Query Hooks

Example of fetching data with tRPC queries:

components/items/items-list.tsx
import { trpc } from "@/trpc/client";
 
export function ItemsList({ workspaceSlug }) {
  const { data, isLoading, error } = trpc.items.getMany.useQuery({
    slug: workspaceSlug,
  });
 
  if (isLoading) return <div>Loading items...</div>;
  if (error) return <div>Error: {error.message}</div>;
 
  return (
    <div>
      {data.items.map((item) => (
        <ItemCard key={item.id} item={item} />
      ))}
    </div>
  );
}

Type Safety Benefits

One of tRPC's primary benefits is its strong typing system that ensures your frontend and backend remain in sync.

Automatic Type Inference

Types flow from your backend to frontend without any manual work:

Example: Automatic type inference
// Your IDE will know exactly what data is available
const { data } = trpc.items.getMany.useQuery();
 
// TypeScript error if property doesn't exist
console.log(data.items[0].nonExistentProperty);

Input Validation

Input validation happens at compile time and runtime:

Example: Input validation
// TypeScript error if required parameters are missing
trpc.items.create.useMutation({
  // TypeScript shows exactly what's required here
});

Error Handling

Errors are properly typed and can be handled gracefully:

Example: Error handling
trpc.items.create.useMutation({
  onError: (error) => {
    // Error is properly typed with TRPCError properties
    if (error.data?.code === "UNAUTHORIZED") {
      // Handle unauthorized error
    }
  },
});

Hook Patterns

Common Hook Structure

  1. Import dependencies - Import tRPC client and other utilities
  2. Access tRPC utilities - Get access to the tRPC client
  3. Setup mutation or query - Configure the tRPC operation
  4. Handle success/error cases - Manage toast messages and state updates
  5. Return necessary values - Expose mutate function, loading state, etc.

Cache Invalidation

Example: Cache invalidation
// Invalidate queries after a mutation
const utils = trpc.useUtils();
 
trpc.items.create.useMutation({
  onSuccess: () => {
    // Invalidate the getMany query to refetch data
    utils.items.getMany.invalidate();
 
    // You can also invalidate specific queries by key
    utils.items.getMany.invalidate({ slug: "workspace-slug" });
  },
});

Best Practices

Hook Organization

  • Group related hooks in feature-specific files
  • Keep hooks focused on single responsibilities
  • Provide optional callback props for flexibility
  • Handle errors consistently across hooks

Type Safety

  • Leverage TypeScript's inference capabilities
  • Define input and output types explicitly for complex operations
  • Avoid using 'any' or type assertions
  • Use zod schemas for validation

Performance

  • Use proper cache invalidation strategies
  • Implement optimistic updates for better UX
  • Use suspense mode when appropriate
  • Enable prefetching for frequently accessed data

Error Handling

  • Provide descriptive error messages
  • Use toast notifications for user feedback
  • Handle different error types appropriately
  • Implement proper fallbacks and loading states

Key Features

End-to-End Type Safety

🔄 Full TypeScript integration from backend to frontend

React Query Integration

⚡ Built-in caching, refetching, and state management

Error Handling

🛡️ Standardized error handling with custom error types

Developer Experience

🔍 Autocomplete and type checking in your IDE

Client Configuration

tRPC Client Setup

trpc/client.ts
// trpc/client.ts
import { createTRPCReact } from "@trpc/react-query";
import { type AppRouter } from "./_app";
 
export const trpc = createTRPCReact<AppRouter>();
 
// In your Next.js app layout or provider
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { trpc } from "@/trpc/client";
 
export function Providers({ children }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: "/api/trpc",
        }),
      ],
    })
  );
 
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
}

Creating a New tRPC Route

When adding new tRPC routes, follow these steps to maintain consistency across the application.

1. Define the Router

Create a new router file in trpc/routers/:

trpc/routers/example-router.ts
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/trpc/init";
 
// Input validation schema
const createExampleSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().optional(),
});
 
export const exampleRouter = createTRPCRouter({
  // Query example
  getMany: protectedProcedure
    .input(
      z.object({
        limit: z.number().min(1).max(100).optional().default(10),
        cursor: z.string().optional(),
      })
    )
    .query(async ({ ctx, input }) => {
      // Implementation...
      return {
        items: [],
        nextCursor: undefined,
      };
    }),
 
  // Mutation example
  create: protectedProcedure
    .input(createExampleSchema)
    .mutation(async ({ ctx, input }) => {
      // Implementation...
      return {
        message: "Example created successfully",
      };
    }),
});

2. Add to App Router

Add your new router to the app router in _app.ts:

trpc/routers/_app.ts
import { exampleRouter } from "@/trpc/routers/example-router";
 
export const appRouter = createTRPCRouter({
  // Existing routers...
  example: exampleRouter,
});

3. Create Custom Hooks

Create hooks for your new router:

trpc/hooks/example-hooks.ts
import { trpc } from "@/trpc/client";
import { toast } from "sonner";
import { GLOBAL_ERROR_MESSAGE } from "@/lib/constants";
 
export const useCreateExampleTRPC = ({
  onSuccess,
}: {
  onSuccess?: () => void;
}) => {
  const utils = trpc.useUtils();
 
  const { mutate, isPending } = trpc.example.create.useMutation({
    onSuccess: (res) => {
      toast.success(res.message);
      onSuccess?.();
      utils.example.getMany.invalidate();
    },
    onError: (error) => {
      toast.error(error.message || GLOBAL_ERROR_MESSAGE);
    },
  });
 
  return { mutate, isPending };
};

Additional Resources