Error Handling
Comprehensive error handling patterns for ProductReady applications
Error Handling
ProductReady implements a multi-layered error handling strategy covering client-side, server-side, and API errors.
Overview
| Layer | Technology | Error Types |
|---|---|---|
| Client | React Error Boundary | Render errors, component crashes |
| Routing | Next.js not-found.tsx | 404 pages |
| API | tRPC + TRPCError | Validation, auth, business logic |
| Database | Drizzle ORM | Query errors, constraints |
| External | try/catch | Third-party API failures |
404 Not Found Pages
Next.js Global 404
Create src/app/not-found.tsx for unmatched routes:
// src/app/not-found.tsx
import { NotFoundView } from "~/components/shared/not-found-view";
export default function NotFound() {
return (
<html lang="en">
<body className="bg-background">
<NotFoundView fullScreen />
</body>
</html>
);
}Shared NotFoundView Component
Reusable 404 component for both Next.js pages and SPA routes:
// src/components/shared/not-found-view.tsx
"use client";
import { Button } from "kui/button";
import { FileQuestion, Home } from "lucide-react";
import Link from "next/link";
interface NotFoundViewProps {
title?: string;
description?: string;
homeUrl?: string;
homeText?: string;
fullScreen?: boolean;
}
export function NotFoundView({
title = "Page Not Found",
description = "Sorry, we couldn't find the page you're looking for.",
homeUrl = "/",
homeText = "Go Home",
fullScreen = false,
}: NotFoundViewProps) {
return (
<div className={`flex flex-col items-center justify-center px-4 text-center ${
fullScreen ? "min-h-screen" : "min-h-[60vh]"
}`}>
<div className="mb-6 rounded-full bg-muted p-6">
<FileQuestion className="h-12 w-12 text-muted-foreground" />
</div>
<h1 className="mb-2 text-4xl font-bold tracking-tight">404</h1>
<h2 className="mb-4 text-xl font-semibold">{title}</h2>
<p className="mb-8 max-w-md text-muted-foreground">{description}</p>
<Button asChild>
<Link href={homeUrl}>
<Home className="mr-2 h-4 w-4" />
{homeText}
</Link>
</Button>
</div>
);
}
// Section-specific variants
export function DashboardNotFound() {
return <NotFoundView homeUrl="/dashboard" homeText="Back to Dashboard" />;
}
export function SiteAdminNotFound() {
return <NotFoundView homeUrl="/systemadmin" homeText="Back to Admin" />;
}SPA Route 404 (wouter)
Use in wouter Switch as fallback:
// In spa-client.tsx
import { DashboardNotFound } from "~/components/shared/not-found-view";
<Switch>
{routes.map((route) => (
<Route key={route.path} path={route.path}>
{route.component}
</Route>
))}
{/* 404 fallback */}
<Route>
<DashboardNotFound />
</Route>
</Switch>tRPC Error Handling
TRPCError Codes
| Code | HTTP Status | Use Case |
|---|---|---|
UNAUTHORIZED | 401 | Not logged in |
FORBIDDEN | 403 | No permission |
NOT_FOUND | 404 | Resource doesn't exist |
BAD_REQUEST | 400 | Invalid input |
CONFLICT | 409 | Duplicate resource |
INTERNAL_SERVER_ERROR | 500 | Server error |
Server-Side Error Throwing
// src/server/routers/posts.ts
import { TRPCError } from "@trpc/server";
export const postsRouter = createTRPCRouter({
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const post = await ctx.db.query.posts.findFirst({
where: eq(posts.id, input.id),
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
// Check ownership
if (post.authorId !== ctx.userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this post",
});
}
return post;
}),
create: protectedProcedure
.input(createPostSchema)
.mutation(async ({ ctx, input }) => {
// Validate business rules
if (input.title.length < 3) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Title must be at least 3 characters",
});
}
// Check for duplicates
const existing = await ctx.db.query.posts.findFirst({
where: eq(posts.slug, input.slug),
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: "A post with this slug already exists",
});
}
return ctx.db.insert(posts).values(input).returning();
}),
});Client-Side Error Handling
"use client";
import { trpc } from "~/lib/trpc/client";
import { toast } from "sonner";
export function CreatePostForm() {
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
toast.success("Post created successfully");
},
onError: (error) => {
// Handle specific error codes
if (error.data?.code === "CONFLICT") {
toast.error("This slug is already taken");
} else if (error.data?.code === "FORBIDDEN") {
toast.error("You don't have permission to do this");
} else {
toast.error(error.message || "Something went wrong");
}
},
});
// ...
}Authentication Errors
Protected Procedure
// src/server/trpc.ts
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required",
});
}
return next({ ctx: { ...ctx, userId: ctx.userId } });
});Space Access Errors
export const spaceProcedure = protectedProcedure.use(async ({ ctx, next }) => {
// ... space resolution logic ...
if (!currentSpaceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "No space access. Please create or join a space first.",
});
}
return next({ ctx: { ...ctx, currentSpaceId } });
});System Admin Errors
export const systemAdminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
const admin = await ctx.db
.select()
.from(systemAdmins)
.where(eq(systemAdmins.userId, ctx.userId))
.limit(1);
if (admin.length === 0) {
throw new TRPCError({
code: "FORBIDDEN",
message: "System admin privileges required",
});
}
return next({ ctx: { ...ctx, isSiteAdmin: true } });
});Form Validation Errors
Zod Schema Validation
tRPC automatically validates input with Zod and returns structured errors:
// Server
export const userRouter = createTRPCRouter({
update: protectedProcedure
.input(z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
}))
.mutation(async ({ input }) => {
// Zod validation happens automatically
// Invalid input throws BAD_REQUEST with field errors
}),
});// Client - Display field errors
const updateUser = trpc.users.update.useMutation({
onError: (error) => {
// Zod errors include field-level details
if (error.data?.zodError) {
const fieldErrors = error.data.zodError.fieldErrors;
Object.entries(fieldErrors).forEach(([field, errors]) => {
toast.error(`${field}: ${errors?.join(", ")}`);
});
}
},
});Database Error Handling
Constraint Violations
import { PostgresError } from "postgres";
export const usersRouter = createTRPCRouter({
create: publicProcedure
.input(createUserSchema)
.mutation(async ({ ctx, input }) => {
try {
return await ctx.db.insert(users).values(input).returning();
} catch (error) {
// Handle unique constraint violation
if (error instanceof PostgresError && error.code === "23505") {
throw new TRPCError({
code: "CONFLICT",
message: "Email already registered",
});
}
throw error;
}
}),
});Transaction Errors
export const ordersRouter = createTRPCRouter({
create: protectedProcedure
.input(createOrderSchema)
.mutation(async ({ ctx, input }) => {
return await ctx.db.transaction(async (tx) => {
// Create order
const [order] = await tx.insert(orders).values(input).returning();
// Deduct inventory - may fail
const updated = await tx
.update(inventory)
.set({ quantity: sql`quantity - ${input.quantity}` })
.where(and(
eq(inventory.productId, input.productId),
gte(inventory.quantity, input.quantity)
))
.returning();
if (updated.length === 0) {
// Rollback transaction
throw new TRPCError({
code: "BAD_REQUEST",
message: "Insufficient inventory",
});
}
return order;
});
}),
});External API Error Handling
Billing Provider Errors
export const billingRouter = createTRPCRouter({
createCheckout: spaceProcedure
.input(z.object({ plan: z.enum(["pro"]) }))
.mutation(async ({ ctx, input }) => {
if (!isBillingConfigured()) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Billing is not configured",
});
}
try {
const checkout = await billingProvider.createCheckout({
userId: ctx.userId,
// ...
});
return { url: checkout.url };
} catch (error) {
console.error("[Billing] Checkout failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create checkout session",
});
}
}),
});Webhook Error Handling
// src/app/api/billing/webhook/route.ts
export async function POST(req: Request) {
try {
const signature = req.headers.get("x-signature") || "";
const payload = await req.text();
// Verify signature
const isValid = await provider.verifyWebhook({ signature, payload });
if (!isValid) {
console.error("[Webhook] Invalid signature");
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
// Process event
const event = await provider.parseWebhookEvent(payload, signature);
// ... handle event ...
return NextResponse.json({ received: true });
} catch (error) {
console.error("[Webhook] Error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Webhook failed" },
{ status: 400 }
);
}
}Error Logging
Server-Side Logging
// Always log errors with context
try {
await someOperation();
} catch (error) {
console.error("[ModuleName] Operation failed:", {
error,
userId: ctx.userId,
input,
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Operation failed",
});
}Structured Error Responses
// Don't expose internal details to clients
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong", // User-friendly message
cause: error, // Original error for logging (not sent to client)
});Best Practices
✅ Do's
- Use appropriate TRPCError codes
- Provide user-friendly error messages
- Log errors with context for debugging
- Handle errors at the appropriate layer
- Use transactions for multi-step operations
- Validate input with Zod schemas
❌ Don'ts
- Don't expose stack traces to users
- Don't swallow errors silently
- Don't use generic error messages everywhere
- Don't forget to handle async errors
- Don't log sensitive data (passwords, tokens)
Error Handling Checklist
- Global 404 page (
src/app/not-found.tsx) - SPA route 404 fallbacks
- tRPC error codes for all failure cases
- Form validation with Zod
- Database constraint handling
- External API error handling
- Webhook signature verification
- Error logging with context
- User-friendly error messages