Skip to content
BlackFig

Empty states, errors and loading states AI forgets

AntPublished 5 min read

Quick answer

Every screen that shows data needs at least four states: loading, empty, error and full. AI builders usually generate only the full one. In Next.js 15, add loading.tsx and error.tsx per route, give every list a designed empty state with one clear action, and show pending and error states on every form.

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

Symptom

A new user signs up and sees a blank dashboard with a header and nothing else. A slow request leaves a panel empty for three seconds, then content jumps in. A failed request either shows nothing at all or takes the whole page down. In a Next.js 15 production build with no error boundary, a crash in the browser replaces the page with a single line of text:

The hostname is your own. If the error came from a Server Component, the same line says "server-side" and "server logs" instead, with a digest code underneath. Either way, the user has no way back except the browser's reload button.

Why it happens

AI builders build the screen you describe, with sample data that makes it look finished. The prompt says "a list of projects", so the output is a list of projects, already full. Nobody asked what the list looks like with zero projects, while the projects are loading, or when the request fails, so none of those states exist.

The data code has the same gap. Generated fetch logic usually handles the success case and either ignores the rest or logs it to the console. Next.js gives you file conventions for loading and error states, loading.tsx and error.tsx, but a generated app often has neither, so Next.js falls back to its defaults.

Nielsen Norman Group's guidance on empty states names the cost: a blank panel leaves people unsure whether the system is still loading, has failed or found nothing. Their first guideline is to communicate system status. Missing states break that before the user has done anything.

The fix

These steps assume a Next.js 15 App Router project with shadcn/ui. The ideas apply to any stack.

1. List the states for every data view

For each list, table, chart or detail page, write down which of these it needs. Most need all of them.

StateWhen it showsWhat it needs
LoadingFirst load and navigationA skeleton shaped like the content, or a spinner in a single panel
Empty, first useThe user has not created anything yetWhat will appear here, why it helps, one action to create the first item
Empty, no resultsFilters or search match nothingWhat was searched, and a way to clear the filters
ErrorThe request failedWhat happened in plain words, a retry, and the rest of the page still working
PartialSome data loaded, some did notThe data that loaded, plus an inline error for the rest
FullThe normal caseWhat the AI already built

The two empty states are different and need different copy. "No projects yet" with a Create button is wrong when the user has 40 projects and a filter that hides them all.

2. Add a loading.tsx shaped like the page

Next.js wraps a route's page.tsx in a Suspense boundary when there is a loading.tsx in the same folder, and shows it instantly while the page's data streams in. Shared layouts stay interactive. Make it look like the page it stands in for, so nothing jumps when the content arrives:

// app/(app)/projects/loading.tsx
import { Skeleton } from "@/components/ui/skeleton"
 
export default function Loading() {
  return (
    <div className="grid gap-6" aria-busy="true">
      <div className="flex items-center justify-between">
        <Skeleton className="h-7 w-40" />
        <Skeleton className="h-9 w-32" />
      </div>
      <div className="grid gap-2">
        {Array.from({ length: 6 }).map((_, i) => (
          <Skeleton key={i} className="h-12 w-full" />
        ))}
      </div>
      <span className="sr-only" role="status">Loading projects</span>
    </div>
  )
}

NN/g's skeleton screen guidance is a good rule of thumb: skeletons for full-page loads, spinners for a single module, nothing at all for waits under a second, and a progress bar with an estimate for anything over 10 seconds.

3. Add an error.tsx with a way back

error.tsx must be a Client Component. It receives the error and a reset function that tries to render the segment again. Design it like any other screen:

// app/(app)/projects/error.tsx
"use client"
 
import { useEffect } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
 
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // Send to your error reporting service here
    console.error(error)
  }, [error])
 
  return (
    <div role="alert" className="mx-auto grid max-w-md gap-3 py-16">
      <h2 className="text-lg font-medium">Your projects did not load</h2>
      <p className="text-sm text-muted-foreground">
        This is usually a brief connection problem. Your data is safe. Try again, or go back to the
        dashboard.
      </p>
      <div className="flex gap-2">
        <Button onClick={() => reset()}>Try again</Button>
        <Button variant="outline" asChild>
          <Link href="/dashboard">Back to dashboard</Link>
        </Button>
      </div>
      {error.digest ? (
        <p className="text-xs text-muted-foreground">Reference: {error.digest}</p>
      ) : null}
    </div>
  )
}

The Next.js docs explain that errors from Server Components reach the client with a generic message and a digest, so sensitive details are not leaked. Showing the digest as a reference gives support something to search the server logs for. Add a global-error.tsx at the root too: it replaces the root layout when it renders, so it must include its own html and body tags.

4. Design both empty states

shadcn/ui now has an Empty component with header, media, title, description and content parts. Use it for the first-use state, with copy that says what the area is for:

// app/(app)/projects/projects-empty.tsx
import Link from "next/link"
import { FolderPlus } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
 
export function ProjectsEmpty() {
  return (
    <Empty className="border">
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <FolderPlus />
        </EmptyMedia>
        <EmptyTitle>No projects yet</EmptyTitle>
        <EmptyDescription>
          Projects keep a client's quotes, invoices and files together. Create one to send your first
          quote.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button asChild>
          <Link href="/projects/new">Create a project</Link>
        </Button>
      </EmptyContent>
    </Empty>
  )
}

That follows NN/g's three guidelines: it states the status, teaches what projects are for, and gives a direct path to the first one. For the no-results state, say what matched nothing and offer to clear the filters, without the Create button. Then choose between them in the page:

// app/(app)/projects/page.tsx (excerpt)
if (projects.length === 0) {
  return hasFilters ? <NoResults query={query} /> : <ProjectsEmpty />
}

5. Give every form a pending and error state

A button that does nothing visible for two seconds gets clicked twice. React's useFormStatus reports whether the parent form is submitting, and it must be called from a component rendered inside the form:

// components/submit-button.tsx
"use client"
 
import { useFormStatus } from "react-dom"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
 
export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus()
  return (
    <Button type="submit" disabled={pending}>
      {pending ? <Spinner /> : null}
      {children}
    </Button>
  )
}

The shadcn/ui Spinner already has role="status" and an accessible label. For errors returned by the server action, show them next to the field they belong to, in text, and keep everything the user typed. NN/g's error message guidelines cover the wording: say what went wrong, say how to fix it, never rely on colour alone.

How to prevent it

  • Prompt for states explicitly. When you ask the AI tool for a new screen, ask for its loading, empty, no-results and error states in the same prompt, with the copy.
  • Test as a new user, on a slow connection. Sign up with a fresh account and throttle the network in the browser's developer tools. Most missing states show up in five minutes.
  • Force the error. The Next.js docs point out that React DevTools can toggle error boundaries and Suspense boundaries, so you can see your error.tsx and loading.tsx without breaking anything.
  • Make states part of the component. A DataTable that takes isLoading, error and an empty slot will get used correctly far more often than one that only takes rows.
  • Write the copy once. Keep empty state and error wording in your design rules, so new screens reuse the same tone. Building a design system for your MVP covers where that lives.

When to get help

If missing states are the only problem, the steps above will fix them in a day or two. If the app also crashes, loses data or shows raw server errors, that is an error handling problem underneath the design one, and it is worth fixing properly. Error handling in AI-generated code covers that side, and the free vibe code health check will flag the worst of it.

For the design side, these states are where a product starts to feel cared for. They are a big part of what I do when I rework an AI-built app, because I design them and write them in the same pass. See FindPopups for how that looks in a live product, the UX mistakes AI tools make for the rest of the list, or the design guide for AI-built apps for the full picture.

Sources

Questions founders ask

What should an empty state include?

A short heading that says what is missing, one sentence on what will appear here and why it is useful, and one primary action to create the first item. Nielsen Norman Group's guidance adds that it should make the system status clear, so users know nothing is still loading.

Should I use a skeleton or a spinner?

Nielsen Norman Group recommends skeleton screens for full-page loads and spinners for a single module, such as one card or panel. Waits over about 10 seconds need a progress bar with an estimate.

Is the default Next.js error screen fine for production?

No. It is a plain line of text with no way back into the app. Add an error.tsx to your route segments and a global-error.tsx at the root so users see a designed screen with a retry and a route home.

Ant

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