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
- User-friendly messages - Never expose internal errors or stack traces to users
- Type-safe error codes - All error codes are validated at compile-time
- Internationalized - Error messages support multiple languages
- Graceful degradation - Return
ActionResponseinstead of crashing - Automatic reporting - Critical errors are sent to Sentry
When to Use What
| Scenario | Use | Example |
|---|---|---|
| tRPC procedure | Return ActionResponse | Validation failures, business logic errors |
| Helper function | Throw CustomError | Shared validation, access control checks |
| Unexpected errors | Let middleware catch | Database 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
| Code | Description | When to Use |
|---|---|---|
generic.error | Unexpected error fallback | Unknown errors, last resort |
generic.network | Network connectivity issues | API timeout, connection failed |
generic.database | Database operation failures | Query errors, connection issues |
generic.validation | Invalid input data | Failed schema validation |
generic.not-found | Resource not found | Missing user, org, record |
generic.rate-limited | Too many requests | Rate limit exceeded |
auth.* - Authentication Errors
| Code | Description | When to Use |
|---|---|---|
auth.required | User must be logged in | Accessing protected routes |
auth.permission-denied | Insufficient permissions | Unauthorized actions |
auth.user-not-found | User not found | Login with invalid user |
auth.invalid-token | Token invalid or expired | Bad JWT, expired session |
auth.session-expired | Session expired | Timeout, forced logout |
organization.* - Organization Errors
| Code | Description | When to Use |
|---|---|---|
organization.user-already-member | Already a member | Duplicate invitation |
organization.invitation-invalid | Invalid invitation | Expired/used invite |
organization.cannot-remove-owner | Cannot remove owner | Protect ownership |
Adding New Error Codes
- 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."
}
}- Use the new error code (type-safe):
throw new CustomError("auth.my-new-error");
// ^? Type-checked against customErrors.jsonThe 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
CustomErroris 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):
- Zod errors - Validation errors from schemas
- BetterAuth errors - Auth library errors
- Firebase errors - Firebase Auth errors
- tRPC errors - API errors with codes
- Custom errors -
CustomErrorwith translated codes - API/Auth errors - Better-Auth API errors
- 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/:
| File | Purpose | Example |
|---|---|---|
customErrors.json | Application error messages | "auth.permission-denied": "Access denied" |
toasts.json | Success messages only | "success.saved": "Changes saved!" |
trpcErrors.json | tRPC error codes | "UNAUTHORIZED": "Not authorized" |
authErrors.json | BetterAuth error codes | "INVALID_PASSWORD": "Wrong password" |
firebaseErrors.json | Firebase 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 checkmarkError Toasts
return {
success: false,
message: ctx.t("customErrors.generic.validation"),
};
// Shows red toast with X iconCustom 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.tsxExample 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 ✅
- Return
ActionResponsein tRPC procedures - Prefer graceful failures - Throw
CustomErrorin helpers - When returning isn't possible - Use scoped error codes -
auth.*,organization.*, etc. - Translate all messages - Support both
enandit(or your locales) - Add context to errors - Include relevant IDs or values
- Use
captureScope- Help with debugging in Sentry - Invalidate queries after mutations - Keep UI in sync
Don'ts ❌
- Don't expose internal errors - Translate to user-friendly messages
- Don't throw in procedures - Return
ActionResponseinstead - Don't hardcode error messages - Use translation keys
- Don't use
any- Type your errors properly - Don't forget both locales - Add translations to all language files
- Don't skip error boundaries - Catch rendering errors
- 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