Skip to contentVibraUI
Guide

Dashboards, pages and widgets

A dashboard is a product for one kind of business: a brand, a sidebar, and pages that read one sample store. Install all of it by one name, one page of it, or one card from a page — and mix them, because every page wears the same shell and reads the same data.

Install a dashboard

The commands here use the @vibra namespace: register it once, as Installation shows. The CLI then asks before it replaces shadcn’s button, card and the rest with Vibra’s; answer yes, or pass --overwrite.

One name installs a dashboard whole. E-commerce is 12 pages under one sidebar: each page at its own route, the sidebar as lib/dashboards/ecommerce/nav.ts, and the sample data the pages read.

npx shadcn@latest add @vibra/ecommerce

There are 13, each installed by its name: ecommerce, saas, crm, support, engineering, people, finance, projects, hotel, clinic, real-estate, academy and ai.

Install a page

A page installs alone, at its route, and still wears its dashboard’s sidebar: Sales lands at app/ecommerce/sales/page.tsx and brings nav-ecommerce with it. Its links to the rest of E-commerce point at routes you can install next, or take out of the nav file.

npx shadcn@latest add @vibra/ecommerce-sales

Install a widget

A widget is one card cut out of a page, with the selector it reads. It installs beside where its page would be, so the same import works with the page or without it: Recent signups, from SaaS’s overview, lands as app/saas/components/recent-signups.tsx and app/saas/data.ts.

npx shadcn@latest add @vibra/widget-saas-overview-recent-signups

The card and the selectors it reads are named exports. Render it from a server component, the way its page does:

import { RecentSignups } from "@/app/saas/components/recent-signups"
import { planNames, recentSignups } from "@/app/saas/data"

// A server component: the selectors read the store, the card takes their rows.
export default function Page() {
  return <RecentSignups rows={recentSignups()} planNames={planNames()} />
}

Every widget is on Widgets, each with the page it comes from.

Put two dashboards under one sidebar

Install E-commerce and SaaS side by side and their routes never collide — /ecommerce and /saas. mergeNav joins their sidebars: the first brand leads, every group of both follows in order, and the footers join so Settings appears once. Hand the result to every page through NavOverrideProvider; each page still passes its own activeHref, so the merged sidebar marks the page you are on.

import { mergeNav } from "@/lib/nav-config"
import { NavOverrideProvider } from "@/components/ui/app-shell/nav-override"
import { NAV as ECOMMERCE } from "@/lib/dashboards/ecommerce/nav"
import { NAV as SAAS } from "@/lib/dashboards/saas/nav"

// E-commerce's brand, then the groups of both, and Settings once.
const NAV = mergeNav(ECOMMERCE, SAAS)

// In the root layout (or any layout above both dashboards' routes):
<body>
  <NavOverrideProvider value={{ nav: NAV }}>{children}</NavOverrideProvider>
</body>

Swap the sample data for your API

Every page reads its rows through db, from @/lib/sample-data: db.orders.list(query) for a table, db.orders.all() for a chart. Every entity on it is a Repository — list, get, create, update, remove and all — kept in memory over seeded rows; the exchange rates, read whole and never written, are the one plain record.

Swap one entity at a time, behind the same interface. In lib/sample-data/orders.ts, keep the Order type and the ORDERS seed, and replace only the exported orders repository with one over your API:

lib/sample-data/orders.ts
import { type Repository } from "./core"

// Order, generate() and ORDERS stay as they are: reviews.ts and shipments.ts
// build their rows from ORDERS. Only the exported repository changes.
export const orders: Repository<Order> = {
  list: (query) => api.orders.list(query), // Promise<Page<Order>>
  get: (id) => api.orders.get(id), // Promise<Order | undefined>
  create: (input) => api.orders.create(input), // Promise<Result<Order>>
  update: (id, patch) => api.orders.update(id, patch),
  remove: (id) => api.orders.remove(id),
  // Synchronous: rows loaded before any page imports its data module.
  all: () => loadedOrders,
}

api stands for your own client, and loadedOrders for rows you have already fetched. list resolves to a Page, and create, update and remove to a Result — the shapes in lib/sample-data/core.ts, where the in-memory repository shows what each one returns. Before you swap, look at what else reads orders:

  • reviews.ts and shipments.ts build their own rows from ORDERS, which is why it stays: they go on describing the sample orders until you swap them too.
  • all() answers synchronously, and every page calls it inside the selector that needs it, as the page renders — none as its data module is imported — so the rows have to be in memory by the time a page renders, and a write reaches every page on its next render.
  • No page reaches the store from its client code: every island is handed its rows by the server component that renders it, so a repository that can only run on the server works for all of them.