Skip to contentVibraUI
Shared pagesinstalls at /error

Error states

The two ways a page fails: a 500 with the details behind a toggle, the reference, the incident already open and a way to try again, and a scheduled-maintenance notice with the window and what it takes down.

Open the live page

To make this Next's own error boundary, copy components/error-boundary.tsx to app/error.tsx and default-export it: that file must be a client component, and its default export takes exactly the props the component already declares. In this version of Next the boundary is handed retry() — which re-fetches and re-renders the segment — alongside reset(), which only clears the boundary; ErrorBoundary prefers retry, falls back to reset, and offers no button at all when it is given neither, rather than one that does nothing. The page beside it is the preview: it renders the same boundary, so what is on screen is what a caught error renders, and a segmented control switches to the maintenance variant. A 500 page is only useful if it can say whether the team already knows, so the incident named on it is the most recent one db.incidents still has open and the services under the maintenance variant are the ones db.services reports as being worked on; the window is derived from REFERENCE_DATE. The links out go through next/link. Composes AppShell, PageHeader, SegmentedControl, ErrorState, EmptyState and StatusBadge.

Preview

Install

npx shadcn@latest add @vibra/states-error

Needs the @vibra registry in your components.json — set it up once.

Source

app/error/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { ErrorVariants } from "./components/error-variants"
import { currentUser, DEMO_ERROR, maintenance, openIncident, shellNotifications } from "./data"
import { NAV, ROUTE } from "./nav"

/**
 * The two ways a page fails, inside the shell so the reader keeps a way out.
 * The boundary itself lives in `components/error-boundary.tsx` and is what
 * gets copied to `app/error.tsx`; this page renders it, so what is on screen
 * here is exactly what a caught error renders there.
 */
export default function ErrorStatesPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTE}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Something went wrong"
        description="What Northwind shows when a page throws, and what it shows when it is down on purpose."
        meta={
          <>
            Reference <span className="font-mono">{DEMO_ERROR.digest}</span>
          </>
        }
      />

      <ErrorVariants
        error={{ message: DEMO_ERROR.message, digest: DEMO_ERROR.digest }}
        incident={openIncident()}
        maintenance={maintenance()}
      />
    </AppShell>
  )
}
app/error/nav.ts
import { type NavConfig } from "@/lib/nav-config"

/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/error"

/**
 * This product's navigation, as plain data. Nothing here points at this page:
 * a state route is somewhere you land, never somewhere you navigate to, so no
 * item lights up and the trail is the brand alone — which is the point. The
 * nav is here so the reader has a way back out.
 */
export const NAV: NavConfig = {
  brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
  groups: [
    {
      label: "Workspace",
      items: [
        { title: "Overview", href: "/saas", icon: "layout-dashboard" },
        { title: "Inbox", href: "/support/inbox", icon: "inbox" },
        { title: "Calendar", href: "/projects/calendar", icon: "calendar" },
        { title: "Customers", href: "/ecommerce/customers", icon: "users" },
      ],
    },
    {
      label: "Platform",
      items: [
        { title: "AI usage", href: "/ai", icon: "sparkles" },
        { title: "Reports", href: "/reports", icon: "file-text" },
      ],
    },
  ],
  // Pinned under the groups, the way the secondary links were.
  footer: [
    { title: "Settings", href: "/settings", icon: "settings" },
    { title: "Support", href: "/support", icon: "life-buoy" },
  ],
}
app/error/data.ts
/**
 * What this page reads. A 500 page is only useful if it can say whether the
 * team already knows, so the incident named on it is the most recent one
 * `db.incidents` still has open, and the services listed under the maintenance
 * variant are the ones `db.services` reports as being worked on. The window is
 * derived from `REFERENCE_DATE`, never from a clock.
 */
import { formatDate, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Member } from "@/lib/sample-data"

/**
 * The error the preview shows. A real `error.tsx` is handed one by React; this
 * stands in for it, and carries a digest the way a production error does.
 */
export const DEMO_ERROR: Error & { digest?: string } = Object.assign(
  new Error("Failed to load overview: upstream request timed out after 30000ms"),
  { digest: "3891274465" }
)

export type OpenIncident = {
  id: string
  title: string
  severity: string
  status: string
  since: string
}

/** The most recent incident still open, or null when nothing is on fire. */
export function openIncident(): OpenIncident | null {
  const incident = db.incidents
    .all()
    .filter((row) => row.status !== "resolved")
    .sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime())[0]

  if (!incident) return null
  return {
    id: incident.id,
    title: incident.title,
    severity: incident.severity.toUpperCase(),
    status: incident.status,
    since: formatDate(incident.startedAt, "medium", { timeZone: "UTC" }),
  }
}

const HOUR_MS = 3_600_000

/** Tonight's window, measured off "now" rather than read from a clock. */
const MAINTENANCE_FROM = new Date(
  Date.UTC(
    REFERENCE_DATE.getUTCFullYear(),
    REFERENCE_DATE.getUTCMonth(),
    REFERENCE_DATE.getUTCDate(),
    22
  )
)

// The window is quoted in UTC, so its date is read in UTC too — formatting the
// day locally and the hours in UTC would put an overnight window on the wrong
// date for every reader east of Greenwich.
const DAY = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  year: "numeric",
  timeZone: "UTC",
})

const TIME = new Intl.DateTimeFormat("en-US", {
  hour: "numeric",
  minute: "2-digit",
  timeZone: "UTC",
  timeZoneName: "short",
})

export type Maintenance = {
  window: string
  services: string[]
}

/** When the work runs and what it takes down with it. */
export function maintenance(): Maintenance {
  return {
    window: `${DAY.format(MAINTENANCE_FROM)}, ${TIME.format(MAINTENANCE_FROM)} – ${TIME.format(
      new Date(MAINTENANCE_FROM.getTime() + 4 * HOUR_MS)
    )}`,
    services: db.services
      .all()
      .filter((service) => service.status === "maintenance")
      .map((service) => service.name),
  }
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/error/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/error/components/error-boundary.tsx
"use client"

import { ErrorState } from "@/components/ui/error-state"

export type ErrorBoundaryProps = {
  /** Whatever React caught. In production its `message` is generic and `digest` identifies it in the logs. */
  error: Error & { digest?: string }
  /**
   * Next's preferred recovery: re-fetches and re-renders the segment. This is
   * the prop `app/error.tsx` is handed in this version of Next — `reset` is
   * still passed beside it, and clears the boundary without re-fetching.
   */
  retry?: () => void
  /** Used when `retry` is absent. */
  reset?: () => void
}

/**
 * The error boundary itself, kept apart from the page so it can be copied to
 * `app/error.tsx` unchanged: that file must be a client component and its
 * default export takes exactly these props.
 */
export function ErrorBoundary({ error, retry, reset }: ErrorBoundaryProps) {
  // Whichever recovery the caller has; without either, the button is not
  // offered rather than offered and dead.
  const recover = retry ?? reset

  return (
    <ErrorState
      title="The overview could not load"
      description="Northwind logged this and the team can see it. Trying again often clears it — the request may simply have taken too long."
      error={error}
      showDetails
      onRetry={recover}
      retryText="Try again"
    >
      {error.digest ? (
        <p className="text-xs text-muted-foreground">
          Reference <span className="font-mono">{error.digest}</span>
        </p>
      ) : null}
    </ErrorState>
  )
}

export default ErrorBoundary
app/error/components/error-variants.tsx
"use client"

import * as React from "react"
import Link from "next/link"
import { WrenchIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { StatusBadge } from "@/components/ui/status-badge"

import { type Maintenance, type OpenIncident } from "../data"
import { ErrorBoundary } from "./error-boundary"

const VARIANTS = [
  { value: "server", label: "Server error" },
  { value: "maintenance", label: "Maintenance" },
]

/**
 * The two ways a page fails, side by side, so a consumer can see both before
 * copying either. The server variant renders the real boundary, so what is on
 * screen is exactly what `app/error.tsx` renders.
 */
export function ErrorVariants({
  error,
  incident,
  maintenance,
}: {
  /**
   * The caught error the boundary is shown with, as plain data: an `Error`
   * cannot cross from a server component, so the island makes one of it.
   */
  error: { message: string; digest?: string }
  incident: OpenIncident | null
  maintenance: Maintenance
}) {
  const [variant, setVariant] = React.useState("server")
  const [retries, setRetries] = React.useState(0)
  const caught = React.useMemo(
    () => Object.assign(new Error(error.message), { digest: error.digest }),
    [error.message, error.digest]
  )

  return (
    <section aria-label="Error states" className="flex flex-col gap-4">
      <SegmentedControl
        size="sm"
        aria-label="Which state"
        options={VARIANTS}
        value={variant}
        onValueChange={(value) => {
          setVariant(value)
          setRetries(0)
        }}
        className="self-start"
      />

      {variant === "server" ? (
        <div className="flex flex-col gap-4 panel p-6">
          <ErrorBoundary error={caught} retry={() => setRetries((count) => count + 1)} />

          {retries > 0 ? (
            <p role="status" className="text-center text-xs text-muted-foreground">
              Retried {retries} {retries === 1 ? "time" : "times"} — still failing.
            </p>
          ) : null}

          {incident ? (
            <div className="flex flex-wrap items-center justify-center gap-2 border-t pt-4 text-xs text-muted-foreground">
              <StatusBadge status={incident.status} size="sm" />
              <span>
                {incident.title} · open since {incident.since}
              </span>
              <span className="font-mono">{incident.id}</span>
              <Link
                href="/status"
                className={cn(buttonVariants({ variant: "link", size: "xs" }))}
              >
                Status page
              </Link>
            </div>
          ) : null}
        </div>
      ) : (
        <EmptyState
          variant="dashed"
          icon={<WrenchIcon />}
          title="Scheduled maintenance"
          description={`Northwind is being upgraded and will be back shortly. Window: ${maintenance.window}.`}
          action={
            <Link href="/status" className={cn(buttonVariants())}>
              Follow on the status page
            </Link>
          }
          secondaryAction={
            <Link href="/support" className={cn(buttonVariants({ variant: "outline" }))}>
              Contact support
            </Link>
          }
        >
          {maintenance.services.length ? (
            <p className="text-xs text-muted-foreground">
              Affected: {maintenance.services.join(", ")}.
            </p>
          ) : null}
        </EmptyState>
      )}
    </section>
  )
}