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

Error Handling

Comprehensive error handling patterns for robust applications

Overview

Error handling in this project follows a structured, type-safe approach that ensures users see friendly error messages while developers get detailed debugging information. The system supports internationalization, automatic toast notifications, and integration with Sentry for error tracking.

Error Handling Philosophy

Core Principles

  1. User-friendly messages - Never expose internal errors or stack traces to users
  2. Type-safe error codes - All error codes are validated at compile-time
  3. Internationalized - Error messages support multiple languages
  4. Graceful degradation - Return ActionResponse instead of crashing
  5. Automatic reporting - Critical errors are sent to Sentry

When to Use What

ScenarioUseExample
tRPC procedureReturn ActionResponseValidation failures, business logic errors
Helper functionThrow CustomErrorShared validation, access control checks
Unexpected errorsLet middleware catchDatabase connection issues, unhandled exceptions

CustomError Class

The CustomError class provides type-safe error codes that automatically map to translated messages.

Basic Usage

Location: src/lib/errors/custom-error.ts

import { CustomError } from "@/lib/errors/custom-error";

// Simple error with translated message
throw new CustomError("auth.permission-denied");

// Error with additional context
throw new CustomError("generic.not-found", "User with ID 123 not found");

// Error with dynamic data
throw new CustomError("organization.user-already-member", undefined, {
  userId: "123",
  orgId: "456",
});

Error Scopes

Error codes are organized by scope in src/messages/dictionaries/en/customErrors.json:

generic.* - General Errors

CodeDescriptionWhen to Use
generic.errorUnexpected error fallbackUnknown errors, last resort
generic.networkNetwork connectivity issuesAPI timeout, connection failed
generic.databaseDatabase operation failuresQuery errors, connection issues
generic.validationInvalid input dataFailed schema validation
generic.not-foundResource not foundMissing user, org, record
generic.rate-limitedToo many requestsRate limit exceeded

auth.* - Authentication Errors

CodeDescriptionWhen to Use
auth.requiredUser must be logged inAccessing protected routes
auth.permission-deniedInsufficient permissionsUnauthorized actions
auth.user-not-foundUser not foundLogin with invalid user
auth.invalid-tokenToken invalid or expiredBad JWT, expired session
auth.session-expiredSession expiredTimeout, forced logout

organization.* - Organization Errors

CodeDescriptionWhen to Use
organization.user-already-memberAlready a memberDuplicate invitation
organization.invitation-invalidInvalid invitationExpired/used invite
organization.cannot-remove-ownerCannot remove ownerProtect ownership

Adding New Error Codes

  1. Add translation in both locale files:
// src/messages/dictionaries/en/customErrors.json
{
  "auth": {
    "my-new-error": "You don't have permission to perform this action."
  }
}
// src/messages/dictionaries/it/customErrors.json
{
  "auth": {
    "my-new-error": "Non hai il permesso di eseguire questa azione."
  }
}
  1. Use the new error code (type-safe):
throw new CustomError("auth.my-new-error");
//                     ^? Type-checked against customErrors.json

The CustomErrorCode type is automatically updated when you add new keys to the JSON files.

ActionResponse Pattern

When to Return ActionResponse

In tRPC procedures, prefer returning ActionResponse over throwing errors. This provides:

  • Graceful error handling
  • Automatic toast notifications
  • No server crashes
  • Better user experience

ActionResponse Type

Location: src/types/index.ts

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

Usage in tRPC Procedures

Validation failure (prefer this over throwing):

import type { ActionResponse } from "@/types";

export const userRouter = createTRPCRouter({
  deleteUser: authProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const userToDelete = await ctx.db.users.getById(input.id);
      
      // ✅ Return failure response
      if (!userToDelete) {
        return {
          success: false,
          message: ctx.t("customErrors.auth.user-not-found"),
        } satisfies ActionResponse;
      }
      
      await ctx.db.users.deleteById(input.id);
      
      // ✅ Return success response
      return {
        success: true,
        message: ctx.t("toasts.success.deleted"),
      } satisfies ActionResponse;
    }),
});

With payload (returning data):

create: authProcedure
  .input(apiKeyUpsertSchema)
  .mutation(async ({ ctx, input }) => {
    const newApiKey = await ctx.db.apiKeys.create({
      ...input,
      userId: ctx.user.id,
    });
    
    // ✅ Success with payload
    return {
      success: true,
      message: ctx.t("toasts.success.created"),
      payload: newApiKey,
    } satisfies ActionResponse<ApiKey>;
  }),

Conditional response:

const status = await updateSomething(input);

return {
  success: status,
  message: status
    ? ctx.t("toasts.success.saved")
    : ctx.t("customErrors.generic.error"),
} satisfies ActionResponse;

Client-Side Handling

The tRPC client automatically shows toast notifications based on ActionResponse:

const mutation = api.users.deleteUser.useMutation({
  onSuccess: (result) => {
    if (result.success) {
      toast.success(result.message); // ✅ Green toast
      utils.users.list.invalidate();
    } else {
      toast.error(result.message);   // ❌ Red toast
    }
  },
});

When to Throw CustomError

Use throw new CustomError() in helper functions where returning early is not possible:

Access Control Helper

// src/lib/auth/check-access.ts
async function validateUserAccess(userId: string, resourceId: string) {
  const hasAccess = await checkPermission(userId, resourceId);
  
  if (!hasAccess) {
    // ❌ Cannot return ActionResponse from a helper
    // ✅ Must throw to stop execution
    throw new CustomError("auth.permission-denied");
  }
  
  return true;
}

Shared Validation

// src/features/organizations/validate.ts
export function validateOrganizationOwner(org: Organization, userId: string) {
  if (org.ownerId !== userId) {
    throw new CustomError("auth.permission-denied");
  }
}

// Used in multiple procedures
export const orgRouter = createTRPCRouter({
  update: authProcedure
    .input(orgUpsertSchema)
    .mutation(async ({ ctx, input }) => {
      const org = await ctx.db.organizations.getById(input.id);
      
      // Throws if not owner
      validateOrganizationOwner(org, ctx.user.id);
      
      await ctx.db.organizations.update(input);
      
      return { success: true, message: ctx.t("toasts.success.saved") };
    }),
  
  delete: authProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const org = await ctx.db.organizations.getById(input.id);
      
      // Reuse the same validation
      validateOrganizationOwner(org, ctx.user.id);
      
      await ctx.db.organizations.deleteById(input.id);
      
      return { success: true, message: ctx.t("toasts.success.deleted") };
    }),
});

Error Catcher Middleware

The errorCatcherMiddleware in src/trpc/middlewares.ts automatically catches thrown errors and converts them to ActionResponse:

// This middleware catches all errors and returns ActionResponse
export const errorCatcherMiddleware = middleware(async ({ next, ctx }) => {
  try {
    return await next();
  } catch (error) {
    const result = translateError(error, {
      t: ctx.t,
      captureScope: "tRPC",
    });
    
    return {
      success: false,
      message: result.message,
    } satisfies ActionResponse;
  }
});

This means:

  • Thrown CustomError is caught and translated
  • User sees a toast notification
  • No server crashes
  • Error is logged/reported to Sentry

Error Translation

translateError Function

Location: src/lib/errors/translate-error.ts

Translates various error types to user-friendly messages:

import { translateError } from "@/lib/errors/translate-error";

const result = translateError(error, { 
  t, 
  captureScope: "myFunction" 
});
// Returns: { message: string, code?: string, cause?: any }

Error types handled (in order):

  1. Zod errors - Validation errors from schemas
  2. BetterAuth errors - Auth library errors
  3. Firebase errors - Firebase Auth errors
  4. tRPC errors - API errors with codes
  5. Custom errors - CustomError with translated codes
  6. API/Auth errors - Better-Auth API errors
  7. Generic errors - Fallback for unknown errors

Client-Side Hook

import { useTranslateError } from "@/lib/errors/use-translate-error";

function MyComponent() {
  const { translateError } = useTranslateError();
  
  const handleError = (error: unknown) => {
    const result = translateError(error);
    toast.error(result.message);
  };
  
  return <Button onClick={() => doSomething().catch(handleError)} />;
}

Error Message Translations

Translation Files

Error translations are organized in src/messages/dictionaries/:

FilePurposeExample
customErrors.jsonApplication error messages"auth.permission-denied": "Access denied"
toasts.jsonSuccess messages only"success.saved": "Changes saved!"
trpcErrors.jsontRPC error codes"UNAUTHORIZED": "Not authorized"
authErrors.jsonBetterAuth error codes"INVALID_PASSWORD": "Wrong password"
firebaseErrors.jsonFirebase error codes"auth/user-not-found": "User not found"

Adding Custom Error Messages

For application-specific errors (recommended):

// customErrors.json
{
  "feature": {
    "specific-error": "This is a user-friendly error message.",
    "another-error": "Error with context: {value}"
  }
}

For tRPC errors (rare, usually use custom errors instead):

// trpcErrors.json
{
  "TOO_MANY_REQUESTS": "You have made too many requests. Try again {seconds, plural, =0 {now} other {in # seconds}}."
}

Toast Notifications

Toasts are automatically shown for all ActionResponse returns from tRPC:

Success Toasts

return {
  success: true,
  message: ctx.t("toasts.success.saved"),
};
// Shows green toast with checkmark

Error Toasts

return {
  success: false,
  message: ctx.t("customErrors.generic.validation"),
};
// Shows red toast with X icon

Custom Toast

For client-side toasts:

import { toast } from "sonner";

toast.success("Operation completed!");
toast.error("Something went wrong");
toast.info("FYI: Check your email");
toast.warning("Careful with that");

Error Boundaries

Page-Level Error Boundaries

Each route group has an error.tsx file:

src/app/[locale]/(site)/error.tsx
src/app/[locale]/(dashboard)/error.tsx
src/app/[locale]/(admin)/error.tsx

Example src/app/[locale]/(site)/error.tsx:

"use client";

import { ErrorComponent } from "@/layouts/interrupts/error-component";

export default function Error({ 
  error, 
  reset 
}: { 
  error: Error; 
  reset: () => void;
}) {
  return <ErrorComponent error={error} reset={reset} />;
}

Global Error Boundary

Location: src/app/global-error.tsx

Catches unhandled errors at the app level. Useful for catastrophic failures.

"use client";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <h1>Something went wrong!</h1>
        <button onClick={reset}>Try again</button>
      </body>
    </html>
  );
}

Sentry Integration

Errors are automatically captured to Sentry (except validation errors):

Automatic Capture

import { translateError } from "@/lib/errors/translate-error";

// Automatically sends to Sentry when captureScope is provided
const result = translateError(error, { 
  t, 
  captureScope: "userDeletion" 
});

Manual Capture

import { captureException } from "@sentry/nextjs";

try {
  await riskyOperation();
} catch (error) {
  captureException(error, {
    tags: { feature: "api-keys" },
    extra: { userId: ctx.user.id },
  });
  
  return {
    success: false,
    message: ctx.t("customErrors.generic.error"),
  };
}

Filtering Validation Errors

Validation errors (Zod) are not sent to Sentry by default to reduce noise:

// In translateError.ts
if (error instanceof ZodError) {
  // Don't capture to Sentry
  return { message: formatZodError(error) };
}

Best Practices

Do's ✅

  1. Return ActionResponse in tRPC procedures - Prefer graceful failures
  2. Throw CustomError in helpers - When returning isn't possible
  3. Use scoped error codes - auth.*, organization.*, etc.
  4. Translate all messages - Support both en and it (or your locales)
  5. Add context to errors - Include relevant IDs or values
  6. Use captureScope - Help with debugging in Sentry
  7. Invalidate queries after mutations - Keep UI in sync

Don'ts ❌

  1. Don't expose internal errors - Translate to user-friendly messages
  2. Don't throw in procedures - Return ActionResponse instead
  3. Don't hardcode error messages - Use translation keys
  4. Don't use any - Type your errors properly
  5. Don't forget both locales - Add translations to all language files
  6. Don't skip error boundaries - Catch rendering errors
  7. Don't log sensitive data - Sanitize before sending to Sentry

Real-World Examples

Example 1: User Deletion

deleteUser: authProcedure
  .input(z.object({ id: z.string() }))
  .mutation(async ({ ctx, input }) => {
    // Check if user exists
    const user = await ctx.db.users.getById(input.id);
    if (!user) {
      return {
        success: false,
        message: ctx.t("customErrors.auth.user-not-found"),
      } satisfies ActionResponse;
    }
    
    // Check permissions
    if (user.id !== ctx.user.id && !ctx.user.isAdmin) {
      return {
        success: false,
        message: ctx.t("customErrors.auth.permission-denied"),
      } satisfies ActionResponse;
    }
    
    // Delete user
    await ctx.db.users.deleteById(input.id);
    
    return {
      success: true,
      message: ctx.t("toasts.success.deleted"),
    } satisfies ActionResponse;
  }),

Example 2: Organization Invitation

acceptInvitation: authProcedure
  .input(z.object({ invitationId: z.string() }))
  .mutation(async ({ ctx, input }) => {
    const invitation = await ctx.db.invitations.getById(input.invitationId);
    
    // Validate invitation exists
    if (!invitation) {
      return {
        success: false,
        message: ctx.t("customErrors.organization.invitation-invalid"),
      } satisfies ActionResponse;
    }
    
    // Check if already a member (helper throws)
    try {
      await validateNotMember(ctx.user.id, invitation.organizationId);
    } catch (error) {
      // Caught by errorCatcherMiddleware, converted to ActionResponse
      throw error;
    }
    
    // Accept invitation
    await ctx.db.members.create({
      userId: ctx.user.id,
      organizationId: invitation.organizationId,
    });
    
    return {
      success: true,
      message: ctx.t("toasts.success.invitation-accepted"),
    } satisfies ActionResponse;
  }),

Next Steps

  • Understand feature structure: Feature Modules
  • Learn type safety rules: Type Safety Conventions
  • Explore tRPC setup: tRPC Documentation

On this page

Overview
Error Handling Philosophy
Core Principles
When to Use What
CustomError Class
Basic Usage
Error Scopes
generic.* - General Errors
auth.* - Authentication Errors
organization.* - Organization Errors
Adding New Error Codes
ActionResponse Pattern
When to Return ActionResponse
ActionResponse Type
Usage in tRPC Procedures
Client-Side Handling
When to Throw CustomError
Access Control Helper
Shared Validation
Error Catcher Middleware
Error Translation
translateError Function
Client-Side Hook
Error Message Translations
Translation Files
Adding Custom Error Messages
Toast Notifications
Success Toasts
Error Toasts
Custom Toast
Error Boundaries
Page-Level Error Boundaries
Global Error Boundary
Sentry Integration
Automatic Capture
Manual Capture
Filtering Validation Errors
Best Practices
Do's ✅
Don'ts ❌
Real-World Examples
Example 1: User Deletion
Example 2: Organization Invitation
Next Steps