Skip to contentVibraUI
Shared pagesinstalls at /not-found

Not found

A 404 inside the shell: the address that was asked for, a search over everywhere the product goes, and the last few things you changed to pick up from.

Open the live page

The 404 renders inside AppShell rather than on a blank page, because the nav and the search are exactly what someone who has hit a dead end needs. Nothing here points at this route — a state route is somewhere you land, never somewhere you navigate to — so no nav item lights up and the trail is the brand alone; that is the shell behaving correctly, not a missing activeHref. Nothing redirects either: every way out is a link the reader chooses, which is also what keeps the page honest inside a preview frame. Where the reader can go comes from flattenNav(NAV) rather than a list written out, so the page can never offer a destination the product does not have, and what they were last doing comes from db.auditEvents. To use it as Next's own 404, copy page.tsx to app/not-found.tsx and drop REQUESTED_PATH — the segment that threw is already gone, so a real not-found.tsx cannot read the address it was asked for. The links out go through next/link so they navigate client-side; the destination rows too, through the renderLink DataListItem takes. Composes AppShell, PageHeader, EmptyState, SearchInput and DataList.

Preview

Install

npx shadcn@latest add @vibra/states-not-found

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

Source

app/not-found/page.tsx
import Link from "next/link"
import { FileQuestionIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { buttonVariants } from "@/components/ui/button"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { EmptyState } from "@/components/ui/empty-state"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { WhereToGo } from "./components/where-to-go"
import {
  currentUser,
  destinations,
  recentChanges,
  REQUESTED_PATH,
  shellNotifications,
} from "./data"
import { NAV, ROUTE } from "./nav"

/**
 * The 404, inside the shell rather than on a blank page: the nav and the
 * search are exactly what someone who has hit a dead end needs. Nothing
 * redirects — every way out is a link the reader chooses.
 */
export default function NotFoundPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTE}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Page not found"
        description="The link may be out of date, or whatever was here has moved."
        meta={
          <>
            Requested <span className="font-mono">{REQUESTED_PATH}</span>
          </>
        }
      />

      <EmptyState
        variant="dashed"
        icon={<FileQuestionIcon />}
        title="Nothing lives at this address"
        description="No page, no record, and nothing archived under it. If you followed a link from inside Northwind, tell us where — that one is ours to fix."
        action={
          <Link href="/saas" className={cn(buttonVariants())}>
            Back to overview
          </Link>
        }
        secondaryAction={
          <Link href="/support" className={cn(buttonVariants({ variant: "outline" }))}>
            Report a broken link
          </Link>
        }
      />

      <div className="grid gap-4 md:grid-cols-2">
        <WhereToGo destinations={destinations()} />

        <section
          aria-label="Pick up where you left off"
          className="flex flex-col gap-3 panel p-3"
        >
          <h2 className="px-1 text-sm font-medium">Pick up where you left off</h2>
          <DataList divided>
            {recentChanges().map((change) => (
              <DataListItem
                key={change.id}
                className="px-2"
                title={change.resource}
                description={<span className="font-mono">{change.summary}</span>}
                meta={change.when}
              />
            ))}
          </DataList>
        </section>
      </div>
    </AppShell>
  )
}
app/not-found/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 = "/not-found"

/**
 * 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/not-found/data.ts
/**
 * What this page reads. Where the reader can go instead comes from the nav
 * itself rather than a list written out here, so the 404 can never offer a
 * destination the product does not have; what they were last doing comes from
 * `db.auditEvents`, dated against `REFERENCE_DATE`.
 */
import { flattenNav } from "@/lib/nav-config"
import { formatRelative, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Member } from "@/lib/sample-data"

import { NAV } from "./nav"

/**
 * The address that was asked for. A real `not-found.tsx` has no way to read it
 * — the segment that threw is already gone — so a page that wants to name it
 * takes it as a prop; this is the one the preview shows.
 */
export const REQUESTED_PATH = "/reports/q3-forecast"

export type Destination = { title: string; href: string }

/** Everywhere the product goes, taken from the nav so the two cannot drift. */
export function destinations(): Destination[] {
  return flattenNav(NAV).map(({ title, href }) => ({ title, href }))
}

export type RecentChange = {
  id: string
  summary: string
  resource: string
  when: string
}

// Which section each kind of record lives under. A rule over the row, so a
// resource the audit log knows about always resolves to somewhere real.
const SECTION: Record<string, string> = {
  customer: "Customers",
  member: "Members",
  invitation: "Members",
  role: "Roles",
  api_key: "API keys",
  integration: "Integrations",
  billing: "Billing",
  service: "Services",
  deployment: "Deployments",
  alert: "Alerts",
}

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

/** The last three things the signed-in person changed — somewhere to pick up. */
export function recentChanges(): RecentChange[] {
  const owner = ownerRow()
  return db.auditEvents
    .all()
    .filter((event) => event.actor === owner.id)
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 3)
    .map((event) => ({
      id: event.id,
      summary: `${event.action.replace(/[._]/g, " ")} · ${event.resourceId}`,
      resource: SECTION[event.resource] ?? event.resource,
      when: formatRelative(event.at, REFERENCE_DATE),
    }))
}

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/not-found/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/not-found/components/where-to-go.tsx
"use client"

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

import { DataList, DataListItem } from "@/components/ui/data-list"
import { EmptyState } from "@/components/ui/empty-state"
import { SearchInput } from "@/components/ui/search-input"

import { type Destination } from "../data"

/**
 * The way out. Nothing here navigates on its own — every destination is a link
 * the reader chooses, and the field only narrows the list.
 */
export function WhereToGo({ destinations }: { destinations: Destination[] }) {
  const [query, setQuery] = React.useState("")

  const needle = query.trim().toLowerCase()
  const matches = needle
    ? destinations.filter(
        (destination) =>
          destination.title.toLowerCase().includes(needle) ||
          destination.href.toLowerCase().includes(needle)
      )
    : destinations

  return (
    <section aria-label="Where to go" className="flex flex-col gap-3 panel p-3">
      <h2 className="px-1 text-sm font-medium">Where to go</h2>

      <SearchInput
        size="sm"
        clearable
        aria-label="Search destinations"
        placeholder="Search the workspace"
        onValueChange={setQuery}
      />

      {matches.length === 0 ? (
        <EmptyState
          size="sm"
          title="No match"
          description={`Nothing in the workspace is called "${query}".`}
        />
      ) : (
        <DataList divided>
          {matches.map((destination) => (
            <DataListItem
              key={destination.href}
              href={destination.href}
              // Every destination here is a route in this app, so the row goes
              // through the router rather than reloading the document.
              renderLink={(href, linkProps) => <Link {...linkProps} href={href} />}
              className="px-2"
              title={destination.title}
              description={<span className="font-mono">{destination.href}</span>}
              meta={<ArrowRightIcon aria-hidden="true" className="size-3.5" />}
            />
          ))}
        </DataList>
      )}
    </section>
  )
}