Skip to content
BlackFig

Adding the error handling AI tools skip

AntPublished 5 min read

Quick answer

AI tools build the path where everything works, so failed requests, bad input and crashes get a blank screen or nothing at all. Add an error boundary (error.tsx in Next.js) for crashes, check every fetch and Supabase result, return expected errors as values, show a toast for failed actions, and log errors somewhere you will see them.

On this page
  1. Symptom
  2. Why it happens
  3. The fix
  4. How to prevent it
  5. When to get help

Symptom

A user clicks save and nothing happens. A page loads forever. A screen goes white. Or, in a Next.js app running in production, the whole page is replaced with one line of grey text.

That message is what Next.js 15 renders when an error reaches the top of the app with no error boundary to catch it. The server-side version says "server-side" and adds a digest code. In a plain React app built with Vite, there is usually no message at all: React's docs say that when rendering throws, React removes the UI from the screen. Your users see white.

The quieter version is worse. Forms that fail but show a success message anyway. Lists that show "No results" because the request failed, not because there are no results. A payment step that does nothing when the card is declined. Nobody tells you, because from the outside the app just looks broken.

Why it happens

You prompt for features, and the tool builds the version of each feature where everything goes right. Nobody writes "and handle a timeout from the payments API", so that code never appears. The preview always has good wifi, a logged-in user and tidy data, so you never see the gaps.

Underneath, the same four patterns show up in almost every AI-built codebase I open:

  • Unchecked fetch calls. MDN is explicit that a fetch promise does not reject when the server responds with an error status like 404 or 504. It only rejects on network failure. Code that calls res.json() without checking res.ok treats a server error as data.
  • Ignored Supabase errors. Supabase's docs say every supabase-js call returns a data and error pair instead of throwing. Generated code often destructures data and drops error, so a failed query looks like an empty list.
  • No error boundaries. One component throws while rendering and the whole app goes with it, because nothing catches it closer to where it happened.
  • No logging. Errors go to the browser console on the user's phone and nowhere else. You find out from a one-star message, if at all.

There is also a misunderstanding that makes the fixes go wrong. Error boundaries only catch errors thrown while rendering. React's docs list what they do not catch: event handlers, server-side rendering, errors in the boundary itself, and asynchronous code like setTimeout. A save button that throws in its click handler will not trigger error.tsx.

The fix

These steps assume a Next.js 15 App Router project with TypeScript, which is what I use. Where the plain React version differs, I say so.

1. Add one place to send errors

Start with a single function every other piece of code calls. For now it can log to the console. Later you point it at an error tracker without touching anything else.

// src/lib/log-error.ts
export function logError(error: unknown, context?: Record<string, unknown>) {
  console.error(error, context);
  // Send to your error tracking service here once you have one.
}

2. Add error.tsx to each route segment

In the App Router, an error.tsx file wraps its route segment in a React error boundary. The Next.js docs require it to be a Client Component, and it receives the error and a reset function that tries to render the segment again.

// src/app/dashboard/error.tsx
"use client"; // Error boundaries must be Client Components
 
import { useEffect } from "react";
import { logError } from "@/lib/log-error";
 
export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    logError(error, { digest: error.digest });
  }, [error]);
 
  return (
    <div role="alert" className="mx-auto max-w-md space-y-4 p-6">
      <h2 className="text-lg font-semibold">This page didn't load</h2>
      <p className="text-sm text-muted-foreground">
        Try again. If it keeps happening, contact support and quote{" "}
        {error.digest ?? "the time it happened"}.
      </p>
      <button
        type="button"
        onClick={() => reset()}
        className="rounded-md bg-primary px-4 py-2 text-primary-foreground"
      >
        Try again
      </button>
    </div>
  );
}

Two details from the docs matter here. In production, errors from Server Components arrive with a generic message, so don't show error.message to users. Use error.digest to match the error to your server logs. And errors bubble to the nearest boundary, so put error.tsx in each major section, not only at the root, and the rest of the app keeps working when one part fails.

For errors in the root layout itself, add app/global-error.tsx. It replaces the root layout when active, so the docs say it must render its own html and body tags.

// src/app/global-error.tsx
"use client";
 
export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    // global-error must include html and body tags
    <html lang="en">
      <body>
        <h2>Something went wrong</h2>
        <button type="button" onClick={() => reset()}>Try again</button>
      </body>
    </html>
  );
}

3. In a plain React app, write the boundary yourself

Without Next.js, there is no file convention. React's docs note there is no way to write an error boundary as a function component, so it is a small class, or the react-error-boundary package, which the React docs point to.

// src/components/error-boundary.tsx
import { Component, type ErrorInfo, type ReactNode } from "react";
import { logError } from "@/lib/log-error";
 
type Props = { fallback: ReactNode; children: ReactNode };
type State = { hasError: boolean };
 
export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };
 
  static getDerivedStateFromError(): State {
    return { hasError: true };
  }
 
  componentDidCatch(error: Error, info: ErrorInfo) {
    logError(error, { componentStack: info.componentStack });
  }
 
  render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}

Wrap each route and any risky widget, such as a chart or a third-party embed, in its own boundary.

4. Check every request

Make a failed response throw, so it can't pass for data.

// src/lib/get-json.ts
export async function getJSON<T>(url: string, init?: RequestInit): Promise<T> {
  const res = await fetch(url, init);
  if (!res.ok) {
    throw new Error(`Request to ${url} failed with status ${res.status}`);
  }
  return (await res.json()) as T;
}

Do the same for Supabase. Search the project for const { data } = and give every one an error check.

// Before: a failed query looks exactly like an empty list
const { data } = await supabase.from("bookings").select("*");
 
// After
const { data, error } = await supabase.from("bookings").select("*");
if (error) throw new Error(error.message); // caught by the nearest error boundary

5. Return expected errors as values

Not every error is a crash. A taken email address or a declined card is part of normal use. The Next.js docs recommend modelling these as return values from Server Functions, not thrown errors, and showing them with useActionState.

// src/app/projects/actions.ts
"use server";
 
import { createClient } from "@/lib/supabase/server"; // your Supabase server client
 
export type FormState = { ok: boolean; message: string };
 
export async function createProject(_prev: FormState, formData: FormData): Promise<FormState> {
  const title = String(formData.get("title") ?? "").trim();
  if (!title) return { ok: false, message: "Add a project name." };
 
  const supabase = await createClient();
  const { error } = await supabase.from("projects").insert({ title });
  if (error) {
    console.error("createProject failed", error);
    return { ok: false, message: "Couldn't save the project. Try again in a moment." };
  }
  return { ok: true, message: "Project created." };
}
// src/app/projects/new-project-form.tsx
"use client";
 
import { useActionState } from "react";
import { createProject, type FormState } from "./actions";
 
const initialState: FormState = { ok: false, message: "" };
 
export function NewProjectForm() {
  const [state, formAction, pending] = useActionState(createProject, initialState);
 
  return (
    <form action={formAction} className="space-y-3">
      <label htmlFor="title">Project name</label>
      <input id="title" name="title" required className="w-full rounded-md border px-3 py-2" />
      {state.message && <p aria-live="polite">{state.message}</p>}
      <button disabled={pending}>{pending ? "Saving..." : "Create project"}</button>
    </form>
  );
}

6. Catch errors in event handlers and show a toast

Click handlers need their own handling. A toast is a good fit for actions where the user stays on the same screen. Sonner's toast.promise shows a loading state, then success or error, from one promise.

"use client";
 
import { toast } from "sonner";
import { getJSON } from "@/lib/get-json";
 
export function SaveButton({ values }: { values: { name: string } }) {
  function onSave() {
    toast.promise(
      getJSON("/api/profile", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(values),
      }),
      {
        loading: "Saving...",
        success: "Profile saved",
        error: "Couldn't save your profile. Check your connection and try again.",
      }
    );
  }
 
  return <button type="button" onClick={onSave}>Save</button>;
}

For a failed load the user can retry, pass an action: toast.error("Couldn't load bookings", { action: { label: "Retry", onClick: () => refetch() } }). Remember to render <Toaster /> once, in the root layout.

Toasts disappear, so don't use them for anything the user needs to read and act on later. Form validation belongs next to the field, and a failed page load belongs in the page.

7. Give every screen an empty, loading and error state

This is the design half of error handling, and AI tools skip it just as often. Every list needs to say "nothing here yet" differently from "couldn't load this". Every loading state needs to end. I cover the design side in empty, error and loading states.

How to prevent it

  • Prompt for failure. For each feature, ask the tool what happens when the request fails, the input is wrong and the user is offline. Then ask it to handle each case.
  • Make the safe pattern the easy one. Once getJSON, logError and your form pattern exist, new code can use them. Generated code copies whatever is nearby.
  • Test with the network off. Use your browser's developer tools to simulate a slow or offline connection, and go through every screen that way once before a release.
  • Watch the log. Point logError at an error tracker, and look at it weekly. An error nobody sees is the same as no error handling.

When to get help

If your app is small, the steps above are an afternoon of work, and you will learn a lot about your own code doing them. If you find that errors are ignored in hundreds of places, or fixing one surfaces three more, the problem is deeper than missing try and catch blocks. It is usually a data layer with no consistent pattern, which is covered in my guide to fixing a vibe-coded app.

Error handling is one line on the vibe-code rescue checklist, and broken mobile layouts tend to turn up alongside it. The free health check is a quick way to see where you stand. If you would rather hand it over, a Rescue Sprint adds error handling and logging across the app at a fixed price, agreed after the health check.

Sources

The vibe-code rescue checklist, as a PDF

About 40 checks across auth, data, payments, errors, mobile and secrets, on two pages.

You get the PDF now and two short follow-ups over the next week. Reply “stop” and the rest never arrive.

Questions founders ask

Why does my app show a white screen instead of an error?

React's docs say that if a component throws while rendering, React removes the UI from the screen unless an error boundary catches it. In a Next.js production build you see a short generic message instead. Either way, the fix is an error boundary around each part of the app.

Does error.tsx catch errors in button clicks?

No. Error boundaries catch errors during rendering. Errors in event handlers and most async code need a try and catch, with the result stored in state or shown as a toast. Errors thrown inside startTransition are the exception and do reach the boundary.

Why didn't fetch throw when my API returned a 500?

MDN explains that a fetch promise only rejects on network failure. A 404 or 500 response still resolves, so you have to check response.ok yourself.

Do I need an error tracking service?

Once real users arrive, yes. Without one, the only way you hear about errors is from users who bother to tell you. Pick one, point your logging function at it, and check it weekly.

Ant

Product designer who builds MVPs, based in Nottingham. 10+ years designing digital products, and the only person at BlackFig. More about me