Documentation
Documentation
Introduction

Getting Started

Getting StartedInstallationQuick StartProject Structure

Architecture

Architecture OverviewTech StacktRPC MiddlewareDesign Principles

Patterns

Code Patterns & ConventionsFeature ModulesError HandlingType Safety

Database

DatabaseSchema DefinitionDatabase OperationsMigrationsCaching

API

tRPCProceduresRouterstRPC Proxy Setup
APIsOpenAPIREST Endpoints

Auth & Access

AuthenticationConfigurationOAuth ProvidersRolesSession Management
AuthorizationUser RolesPermissions

Routing & i18n

RoutingDeclarative RoutingNavigation
InternationalizationTranslationsLocale Routing

Components & UI

ComponentsButtonsFormsNavigationDialogs
StylesTailwind CSSThemingTypography

Storage

StorageConfigurationUsageBuckets

Configuration

ConfigurationEnvironment VariablesFeature Flags

Templates

Template GuidesCreate New FeatureCreate New PageCreate Database TableCreate tRPC RouterAdd Translations

Development

DevelopmentCommandsAI AgentsBest Practices

Create tRPC Router

Step-by-step guide for creating a type-safe tRPC API router with CRUD operations

Overview

This guide walks you through creating a tRPC router with type-safe procedures. tRPC provides end-to-end type safety from server to client without code generation or runtime overhead.

tRPC routers automatically provide TypeScript types to React hooks, eliminating the need for manual API type definitions.

Prerequisites

Before creating a router, gather:

Step 1: Router Name

Choose a descriptive name in camelCase (e.g., notifications, projects, apiKeys)

Step 2: Procedures

List operations needed: list, getById, create, update, upsert, delete, etc.

Step 3: Auth Level

Determine authentication requirements:

  • publicProcedure - No auth required
  • authProcedure - User must be authenticated
  • roleProcedure - Specific role required (admin, etc.)

Step 1: Create Router File

Create src/trpc/procedures/routers/<router-name>.ts:

import {
  <feature>DeleteSchema,
  <feature>GetSchema,
  <feature>UpsertSchema,
} from "@/features/<feature-name>/schema";
import {
  authProcedure,
  createTRPCRouter,
} from "@/trpc/procedures/trpc";
import type { ActionResponse } from "@/types";

export const <feature>sRouter = createTRPCRouter({
  // List all items for current user
  list: authProcedure
    .meta({ rateLimit: "QUERY" })
    .query(async ({ ctx }) => {
      const items = await ctx.db.<feature>s.getByOwnerId(ctx.user.id);
      
      return {
        success: true,
        payload: items,
      } satisfies ActionResponse;
    }),
  
  // Get single item by ID
  getById: authProcedure
    .meta({ rateLimit: "QUERY" })
    .input(<feature>GetSchema)
    .query(async ({ ctx, input }) => {
      const item = await ctx.db.<feature>s.get(input.id);
      
      return {
        success: true,
        payload: item,
      } satisfies ActionResponse;
    }),
  
  // Create or update (upsert)
  upsert: authProcedure
    .meta({ rateLimit: "MUTATION" })
    .input(<feature>UpsertSchema)
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input;
      
      if (id) {
        // Update existing
        const updated = await ctx.db.<feature>s.update(id, data);
        
        return {
          success: true,
          message: ctx.t("toasts.saved"),
          payload: updated,
        } satisfies ActionResponse;
      }
      
      // Create new
      const created = await ctx.db.<feature>s.create({
        ...data,
        ownerId: ctx.user.id,
      });
      
      return {
        success: true,
        message: ctx.t("toasts.saved"),
        payload: created,
      } satisfies ActionResponse;
    }),
  
  // Delete
  delete: authProcedure
    .meta({ rateLimit: "MUTATION" })
    .input(<feature>DeleteSchema)
    .mutation(async ({ ctx, input }) => {
      await ctx.db.<feature>s.remove(input.id);
      
      return {
        success: true,
        message: ctx.t("toasts.deleted"),
      } satisfies ActionResponse;
    }),
});

Step 2: Register Router

In src/trpc/procedures/root.ts:

import { <feature>sRouter } from "@/trpc/procedures/routers/<feature-name>";

export const appRouter = createTRPCRouter({
  // ...existing routers
  <feature>s: <feature>sRouter,
});

export type AppRouter = typeof appRouter;

Router names must be unique - The name becomes part of the API path: api.<feature>s.list()

Procedure Types

Public Procedure

No authentication required - accessible to everyone:

import { publicProcedure } from "@/trpc/procedures/trpc";

getPublicData: publicProcedure
  .meta({ rateLimit: "QUERY" })
  .query(async ({ ctx }) => {
    const data = await ctx.db.<feature>s.list();
    
    return {
      success: true,
      payload: data,
    } satisfies ActionResponse;
  }),

Authenticated Procedure

Requires user to be logged in:

import { authProcedure } from "@/trpc/procedures/trpc";

getUserData: authProcedure
  .meta({ rateLimit: "QUERY" })
  .query(async ({ ctx }) => {
    // ctx.user is guaranteed to exist
    const data = await ctx.db.<feature>s.getByOwnerId(ctx.user.id);
    
    return {
      success: true,
      payload: data,
    } satisfies ActionResponse;
  }),

Role-Based Procedure

Requires specific user role:

import { roleProcedure } from "@/trpc/procedures/trpc";
import { UserRole } from "@/features/auth/schema";

adminOnly: roleProcedure(UserRole.ADMIN)
  .meta({ rateLimit: "MUTATION" })
  .mutation(async ({ ctx, input }) => {
    // Only admins can access this
    // ctx.user.role === UserRole.ADMIN
    
    return {
      success: true,
    } satisfies ActionResponse;
  }),

Rate Limiting

Configure rate limiting per procedure:

.meta({ rateLimit: "QUERY" })    // For read operations
.meta({ rateLimit: "MUTATION" })  // For write operations
.meta({ rateLimit: "AUTH" })      // For auth operations
.meta({ rateLimit: false })       // Disable rate limiting

Rate limit configurations are in src/trpc/procedures/trpc.ts:

TypeRequestsWindowUse Case
QUERY1001 minRead operations
MUTATION201 minWrite operations
AUTH51 minAuth-related operations

Input Validation

With Zod Schema

import { z } from "zod";

getById: authProcedure
  .input(z.object({ id: z.string().uuid() }))
  .query(async ({ ctx, input }) => {
    // input is typed and validated
    const item = await ctx.db.<feature>s.get(input.id);
    return { success: true, payload: item };
  }),

With Complex Schema

import { <feature>UpsertSchema } from "@/features/<feature-name>/schema";

upsert: authProcedure
  .input(<feature>UpsertSchema)
  .mutation(async ({ ctx, input }) => {
    // input validated against full schema
    // ...
  }),

Multiple Inputs

search: authProcedure
  .input(
    z.object({
      query: z.string().min(1),
      page: z.number().int().positive().default(1),
      limit: z.number().int().positive().max(100).default(10),
    })
  )
  .query(async ({ ctx, input }) => {
    const { query, page, limit } = input;
    // ...
  }),

ActionResponse Pattern

All procedures should return ActionResponse for consistency:

type ActionResponse<T = unknown> = {
  success: boolean;
  message?: string;
  payload?: T;
  error?: string;
};

Success Response

return {
  success: true,
  payload: item,
} satisfies ActionResponse;

Success with Message

return {
  success: true,
  message: ctx.t("toasts.saved"),
  payload: item,
} satisfies ActionResponse;

Error Response

return {
  success: false,
  error: "Item not found",
} satisfies ActionResponse;

Translation Context: Use ctx.t() for internationalized messages in responses.

Context Access

Available in all procedures via ctx:

query(async ({ ctx }) => {
  // Database operations
  ctx.db.<feature>s.list();
  
  // Current user (authProcedure only)
  ctx.user.id;
  ctx.user.email;
  ctx.user.role;
  
  // Translations
  ctx.t("key");
  
  // Session
  ctx.session;
  
  // Request headers
  ctx.headers;
});

OpenAPI Integration (Optional)

Expose procedures as REST endpoints:

list: authProcedure
  .meta({
    rateLimit: "QUERY",
    openapi: {
      method: "GET",
      path: "/<feature>s",
      tags: ["<Feature>s"],
      summary: "List all <feature>s",
      description: "Get all <feature>s for the current user",
    },
  })
  .input(z.object({})) // Required for OpenAPI
  .output(
    z.object({
      success: z.boolean(),
      payload: z.array(<feature>Schema),
    })
  ) // Required for OpenAPI
  .query(async ({ ctx }) => {
    const items = await ctx.db.<feature>s.getByOwnerId(ctx.user.id);
    
    return {
      success: true,
      payload: items,
    };
  }),

Access via REST:

GET /api/<feature>s
Authorization: Bearer <token>

OpenAPI Requirements:

  • Must define .input() and .output() schemas
  • Method must be GET, POST, PUT, PATCH, or DELETE
  • Path must be unique

Error Handling

Throwing Errors

import { TRPCError } from "@trpc/server";

getById: authProcedure
  .input(<feature>GetSchema)
  .query(async ({ ctx, input }) => {
    const item = await ctx.db.<feature>s.get(input.id);
    
    if (!item) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "Item not found",
      });
    }
    
    return { success: true, payload: item };
  }),

Error Codes

CodeHTTP StatusUse Case
BAD_REQUEST400Invalid input
UNAUTHORIZED401Not authenticated
FORBIDDEN403No permission
NOT_FOUND404Resource doesn't exist
CONFLICT409Duplicate or version issue
PRECONDITION_FAILED412Condition not met
PAYLOAD_TOO_LARGE413Request too large
TOO_MANY_REQUESTS429Rate limit exceeded
INTERNAL_SERVER_ERROR500Unexpected server error

Validation Errors

upsert: authProcedure
  .input(<feature>UpsertSchema)
  .mutation(async ({ ctx, input }) => {
    // Zod validation errors are automatically handled
    // and returned with proper error messages
  }),

Advanced Patterns

Pagination

list: authProcedure
  .input(
    z.object({
      page: z.number().int().positive().default(1),
      limit: z.number().int().positive().max(100).default(10),
    })
  )
  .query(async ({ ctx, input }) => {
    const { page, limit } = input;
    const offset = (page - 1) * limit;
    
    const items = await ctx.db.<feature>s.listTable({
      offset,
      limit,
    });
    
    return {
      success: true,
      payload: items,
      meta: {
        page,
        limit,
        total: items.length,
      },
    };
  }),

Filtering

list: authProcedure
  .input(
    z.object({
      status: z.enum(["active", "inactive"]).optional(),
      search: z.string().optional(),
    })
  )
  .query(async ({ ctx, input }) => {
    const { status, search } = input;
    
    let whereClause;
    if (status) {
      whereClause = eq(<feature>s.status, status);
    }
    
    const items = await ctx.db.<feature>s.listFiltered(whereClause);
    
    return { success: true, payload: items };
  }),

Batch Operations

import { z } from "zod";

deleteMany: authProcedure
  .input(z.object({ ids: z.array(z.string().uuid()) }))
  .mutation(async ({ ctx, input }) => {
    await Promise.all(
      input.ids.map((id) => ctx.db.<feature>s.remove(id))
    );
    
    return {
      success: true,
      message: ctx.t("toasts.deleted"),
    };
  }),

Conditional Logic

upsert: authProcedure
  .input(<feature>UpsertSchema)
  .mutation(async ({ ctx, input }) => {
    const { id, ...data } = input;
    
    // Different logic for create vs update
    if (id) {
      // Verify ownership before update
      const existing = await ctx.db.<feature>s.get(id);
      if (existing.ownerId !== ctx.user.id) {
        throw new TRPCError({ code: "FORBIDDEN" });
      }
      
      const updated = await ctx.db.<feature>s.update(id, data);
      return { success: true, payload: updated };
    }
    
    // Create new with owner
    const created = await ctx.db.<feature>s.create({
      ...data,
      ownerId: ctx.user.id,
    });
    
    return { success: true, payload: created };
  }),

Complete CRUD Example

import {
  <feature>DeleteSchema,
  <feature>GetSchema,
  <feature>UpsertSchema,
  <feature>Schema,
} from "@/features/<feature-name>/schema";
import {
  authProcedure,
  createTRPCRouter,
  publicProcedure,
} from "@/trpc/procedures/trpc";
import type { ActionResponse } from "@/types";
import { TRPCError } from "@trpc/server";
import { z } from "zod";

export const <feature>sRouter = createTRPCRouter({
  // Public - List all
  listPublic: publicProcedure
    .meta({ rateLimit: "QUERY" })
    .query(async ({ ctx }) => {
      const items = await ctx.db.<feature>s.list();
      return { success: true, payload: items } satisfies ActionResponse;
    }),
  
  // Auth - List user's items
  list: authProcedure
    .meta({ rateLimit: "QUERY" })
    .query(async ({ ctx }) => {
      const items = await ctx.db.<feature>s.getByOwnerId(ctx.user.id);
      return { success: true, payload: items } satisfies ActionResponse;
    }),
  
  // Auth - Get by ID
  getById: authProcedure
    .meta({ rateLimit: "QUERY" })
    .input(<feature>GetSchema)
    .query(async ({ ctx, input }) => {
      const item = await ctx.db.<feature>s.get(input.id);
      
      if (!item) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }
      
      return { success: true, payload: item } satisfies ActionResponse;
    }),
  
  // Auth - Create or update
  upsert: authProcedure
    .meta({ rateLimit: "MUTATION" })
    .input(<feature>UpsertSchema)
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input;
      
      if (id) {
        const updated = await ctx.db.<feature>s.update(id, data);
        return {
          success: true,
          message: ctx.t("toasts.saved"),
          payload: updated,
        } satisfies ActionResponse;
      }
      
      const created = await ctx.db.<feature>s.create({
        ...data,
        ownerId: ctx.user.id,
      });
      
      return {
        success: true,
        message: ctx.t("toasts.saved"),
        payload: created,
      } satisfies ActionResponse;
    }),
  
  // Auth - Delete
  delete: authProcedure
    .meta({ rateLimit: "MUTATION" })
    .input(<feature>DeleteSchema)
    .mutation(async ({ ctx, input }) => {
      await ctx.db.<feature>s.remove(input.id);
      
      return {
        success: true,
        message: ctx.t("toasts.deleted"),
      } satisfies ActionResponse;
    }),
  
  // Auth - Search with filters
  search: authProcedure
    .meta({ rateLimit: "QUERY" })
    .input(
      z.object({
        query: z.string().optional(),
        page: z.number().int().positive().default(1),
        limit: z.number().int().positive().max(100).default(10),
      })
    )
    .query(async ({ ctx, input }) => {
      const { query, page, limit } = input;
      const offset = (page - 1) * limit;
      
      const items = await ctx.db.<feature>s.listTable({
        offset,
        limit,
      });
      
      return {
        success: true,
        payload: items,
        meta: { page, limit },
      } satisfies ActionResponse;
    }),
});

Post-Creation Checklist

  1. Router File

    • Router file created in src/trpc/procedures/routers/
    • All procedures defined
    • Input schemas validated
    • Output uses ActionResponse
  2. Registration

    • Router exported from file
    • Router added to root.ts
    • No TypeScript errors
  3. Security

    • Correct procedure type used (public/auth/role)
    • Rate limiting configured
    • Owner verification in mutations
    • Error handling implemented
  4. Testing

    • Procedures callable from client
    • Input validation works
    • Auth checks enforced
    • Error responses correct

Real-World Examples

See these routers for reference:

  • src/trpc/procedures/routers/api-keys.ts - Full CRUD with RLS
  • src/trpc/procedures/routers/organizations.ts - Complex relationships
  • src/trpc/procedures/routers/auth.ts - Public procedures

Router created! Your type-safe API is ready. Client hooks will automatically have full TypeScript support.

On this page

Overview
Prerequisites
Step 1: Router Name
Step 2: Procedures
Step 3: Auth Level
Step 1: Create Router File
Step 2: Register Router
Procedure Types
Public Procedure
Authenticated Procedure
Role-Based Procedure
Rate Limiting
Input Validation
With Zod Schema
With Complex Schema
Multiple Inputs
ActionResponse Pattern
Success Response
Success with Message
Error Response
Context Access
OpenAPI Integration (Optional)
Error Handling
Throwing Errors
Error Codes
Validation Errors
Advanced Patterns
Pagination
Filtering
Batch Operations
Conditional Logic
Complete CRUD Example
Post-Creation Checklist
Real-World Examples