GameCraftGameCraft

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

LayerTechnologyError Types
ClientReact Error BoundaryRender errors, component crashes
RoutingNext.js not-found.tsx404 pages
APItRPC + TRPCErrorValidation, auth, business logic
DatabaseDrizzle ORMQuery errors, constraints
Externaltry/catchThird-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

CodeHTTP StatusUse Case
UNAUTHORIZED401Not logged in
FORBIDDEN403No permission
NOT_FOUND404Resource doesn't exist
BAD_REQUEST400Invalid input
CONFLICT409Duplicate resource
INTERNAL_SERVER_ERROR500Server 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

On this page