/not-foundNot 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.
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
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>
)
}Install
npx shadcn@latest add @vibra/states-not-foundNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}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" },
],
}/**
* 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 }))
}"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 } }
}"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>
)
}