Build a SaaS dashboard in Next.js with free components

Build a SaaS dashboard in Next.js App Router with free React components: app shell, sidebar nav, KPI cards, charts, a data table, and empty and loading states.

By MiniDev18 min read

A SaaS dashboard in Next.js is a route group with one shared layout (sidebar, top bar, scrolling content area) and pages that fetch data in server components and hand it to a few presentational pieces: a KPI row, chart cards, a data table and an activity feed. MiniDev UI ships each of those as free, shadcn-compatible React components, plus two assembled blocks, dashboard-home and admin-overview, that you can copy as a starting point. This guide wires them into the App Router with real loading, empty and error states.

Project structure

Put the authenticated app in a route group so it gets its own layout without adding a URL segment. Marketing pages stay outside it with their own layout.

bash
app/
  (marketing)/page.tsx
  (app)/
    layout.tsx          # AppShell: sidebar + topbar
    app-nav.tsx         # client: active link state
    dashboard/
      page.tsx          # KPIs, charts, activity
      loading.tsx       # skeletons
      error.tsx         # retry
    customers/page.tsx  # DataTable

Install the pieces. Each command copies the source into components/ui, including registry dependencies such as avatar, list-item and metric-delta. Add the token stylesheet to your global CSS once; it defines the surfaces, text colors and shadows every component uses, in light and dark.

bash
npx shadcn@latest add https://ui.minidev.pro/r/app-shell.json https://ui.minidev.pro/r/sidebar-section.json https://ui.minidev.pro/r/topbar.json https://ui.minidev.pro/r/page-header.json
npx shadcn@latest add https://ui.minidev.pro/r/kpi-row.json https://ui.minidev.pro/r/stat-card.json https://ui.minidev.pro/r/chart-card.json https://ui.minidev.pro/r/area-chart.json
npx shadcn@latest add https://ui.minidev.pro/r/data-table.json https://ui.minidev.pro/r/activity-feed.json https://ui.minidev.pro/r/empty-table.json https://ui.minidev.pro/r/loading-table.json https://ui.minidev.pro/r/retry-block.json

The app shell layout

AppShell takes three slots: sidebar, topbar and children. The sidebar column is 224px wide, sits on the sunken surface, and is hidden below the md breakpoint. The content area scrolls on its own, so the sidebar and top bar stay put. Its default classes (rounded corners, a border, min-h-[420px]) suit a preview card; for a full page, override them. cn uses tailwind-merge, so your classes win.

app/(app)/layout.tsxtsx
import { AppShell } from "@/components/ui/app-shell"
import { Topbar } from "@/components/ui/topbar"
import { AppNav, MobileNav } from "./app-nav"

export default function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <AppShell
      className="h-dvh min-h-0 rounded-none border-0"
      sidebar={<AppNav />}
      topbar={<Topbar left={<MobileNav />} right={<UserMenu />} />}
    >
      <main className="mx-auto max-w-6xl">{children}</main>
    </AppShell>
  )
}

The layout is a server component. AppShell is a client component, but anything you pass as children stays server rendered, so pages can still await data directly.

Group links with SidebarSection, which renders an optional small uppercase title and a stacked list. AppShell already provides the sidebar column, so you do not need the Sidebar container inside it; Sidebar is for shells you build yourself, including its 56px collapsed mode. Its SidebarNavItem is a button, which is right for in-app actions. For page navigation use next/link, so people get prefetching, middle-click and a real URL on hover:

app/(app)/app-nav.tsxtsx
"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { HomeIcon, UsersIcon, CreditCardIcon, SettingsIcon, MenuIcon } from "lucide-react"
import { SidebarSection } from "@/components/ui/sidebar-section"
import { Sheet } from "@/components/ui/sheet"

const NAV = [
  { href: "/dashboard", label: "Overview", icon: HomeIcon },
  { href: "/customers", label: "Customers", icon: UsersIcon },
  { href: "/billing", label: "Billing", icon: CreditCardIcon },
  { href: "/settings", label: "Settings", icon: SettingsIcon },
]

export function AppNav({ onNavigate }: { onNavigate?: () => void }) {
  const pathname = usePathname()
  return (
    <nav aria-label="App">
      <SidebarSection title="Workspace">
        {NAV.map(({ href, label, icon: Icon }) => {
          const active = pathname === href || pathname.startsWith(href + "/")
          return (
            <Link
              key={href}
              href={href}
              onClick={onNavigate}
              aria-current={active ? "page" : undefined}
              data-active={active || undefined}
              className="flex h-9 items-center gap-2 rounded-lg px-2.5 text-sm text-fg-muted transition-[background-color,color] duration-[70ms] hover:bg-fg/5 hover:text-fg data-[active]:bg-surface data-[active]:text-fg data-[active]:shadow-xs"
            >
              <Icon className="size-4" />
              <span className="truncate">{label}</span>
            </Link>
          )
        })}
      </SidebarSection>
    </nav>
  )
}

export function MobileNav() {
  const [open, setOpen] = React.useState(false)
  return (
    <>
      <button type="button" aria-label="Open navigation" className="grid size-9 place-items-center rounded-lg md:hidden" onClick={() => setOpen(true)}>
        <MenuIcon className="size-4" />
      </button>
      <Sheet open={open} onClose={() => setOpen(false)} side="left" title="Navigation" className="max-w-72">
        <AppNav onNavigate={() => setOpen(false)} />
      </Sheet>
    </>
  )
}

Two details: aria-current="page" tells screen readers which link is the current page, and the active style uses the raised surface color because the sidebar column is already sunken. The same nav renders inside a left Sheet on small screens, where the shell hides the sidebar.

KPIs that read correctly

KpiRow lays out up to four tiles in a responsive grid (one column, then two at sm, four at lg). Each item is { label, value, delta }. A numeric delta renders through MetricDelta as a percentage with an arrow, green when it is zero or above and red below. A string delta renders as muted text, which suits context like "30d" or "+2 this week".

tsx
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact" })

<KpiRow
  items={[
    { label: "MRR", value: usd.format(kpis.mrr), delta: kpis.mrrChange },
    { label: "Active users", value: kpis.active.toLocaleString("en-US"), delta: kpis.activeChange },
    { label: "Trials", value: String(kpis.trials), delta: "+" + kpis.newTrials + " this week" },
    { label: "Uptime", value: "99.98%", delta: "30d" },
  ]}
/>

Pick four numbers that someone would act on this week, and make the comparison period explicit in the label or description ("vs last month"). A delta without a period is noise. Format values on the server with Intl.NumberFormat; the tiles expect strings and render them with tabular-nums. For metrics where down is good, such as churn, latency or error rate, use StatCard with invertDelta. It colors a string delta by its sign, and invertDelta flips the meaning so "-0.3%" churn shows as good news.

tsx
<StatCard label="Churn" value="1.1%" delta="-0.3%" invertDelta footnote="vs last month" />

Charts without a charting library

ChartCard is the frame: a section with a title, an optional description and a body with a 160px minimum height. Put any chart inside. MiniDev UI's charts are small SVG components with no dependency: AreaChart takes data: number[], BarChart takes { label, value }[] and a format function, and LineChart takes series: number[][] colored from the --chart-1 to --chart-5 tokens. Strokes use vector-effect: non-scaling-stroke, so lines stay crisp at any width.

tsx
<div className="grid gap-4 lg:grid-cols-5">
  <ChartCard className="lg:col-span-3" title="Active users" description="Daily, last 30 days">
    <AreaChart data={series} label="Daily active users, last 30 days" highlight={series.length - 1} className="h-40" />
  </ChartCard>
  <ChartCard className="lg:col-span-2" title="Signups by channel">
    <BarChart data={channels} format={(n) => n.toLocaleString("en-US")} label="Signups by channel this month" />
  </ChartCard>
</div>

Aggregate on the server and send the chart only the points it draws, such as 30 daily totals, rather than raw events. Always pass label. The SVG has role="img" and uses it as its accessible name, so write a sentence that states what the chart shows. When people need to read exact values, use InteractiveAreaChart: it supports hover and arrow keys, pins a crosshair and shows the delta against a comparison series. For heavy analytics (zooming, thousands of points, many axes), keep ChartCard as the frame and render a full charting library inside it.

Chart CarduiCard wrapper for charts with a title, optional description and a minimum height body. Pairs with the area, bar and donut charts in a React dashboard.npx shadcn@latest add ui.minidev.pro/r/chart-card.json

A data table that feels like a product

DataTable is generic over your row type. Columns declare an id, a header, a cell renderer and, optionally, a sortValue that turns the header into a sort button with the correct aria-sort. It also handles row selection with an indeterminate header checkbox, a floating bulk action bar, hover-revealed row actions, keyboard-activated row clicks, density, a sticky header when you set maxHeight, and built-in skeleton rows when loading is true.

app/(app)/customers/customers-table.tsxtsx
"use client"
import { useRouter } from "next/navigation"
import { DataTable, type Column } from "@/components/ui/data-table"
import { EmptyTable } from "@/components/ui/empty-table"
import { Button } from "@/components/ui/button"

type Customer = { id: string; name: string; plan: string; mrr: number; joined: string }

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })

const columns: Column<Customer>[] = [
  { id: "name", header: "Customer", cell: (r) => r.name, sortValue: (r) => r.name },
  { id: "plan", header: "Plan", cell: (r) => r.plan, sortValue: (r) => r.plan },
  { id: "mrr", header: "MRR", align: "right", cell: (r) => usd.format(r.mrr), sortValue: (r) => r.mrr },
  { id: "joined", header: "Joined", cell: (r) => r.joined.slice(0, 10), sortValue: (r) => new Date(r.joined) },
]

export function CustomersTable({ rows }: { rows: Customer[] }) {
  const router = useRouter()
  return (
    <DataTable
      caption="Customers"
      columns={columns}
      data={rows}
      getRowId={(r) => r.id}
      defaultSort={{ id: "mrr", dir: "desc" }}
      selectable
      bulkActions={(ids) => <Button size="sm" variant="ghost" onClick={() => exportCsv(ids)}>Export</Button>}
      onRowClick={(r) => router.push("/customers/" + r.id)}
      maxHeight={560}
      empty={<EmptyTable title="No customers yet" description="Customers appear here after their first payment." />}
    />
  )
}
  • Define columns at module scope. The table memoizes sorted rows on columns, so a stable reference avoids re-sorting on every render.
  • Right-aligned columns get tabular-nums automatically, so currency lines up by digit.
  • Sorting is client side, over the data you pass. For large datasets, sort and paginate on the server and add TablePagination below the table.
  • caption renders as a visually hidden caption element, which gives the table an accessible name.
  • Keep filters, search and sort in the URL (searchParams) so a filtered view can be bookmarked and shared, and read them in the server page to set defaultSort and the query.

Fetch on the server, stream the slow parts

The dashboard page is an async server component. Fetch what the first screen needs in parallel, render it, and wrap slower widgets in Suspense so they stream in without blocking the KPIs.

app/(app)/dashboard/page.tsxtsx
import { Suspense } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/ui/page-header"
import { KpiRow } from "@/components/ui/kpi-row"
import { ActivityFeed } from "@/components/ui/activity-feed"
import { SkeletonCard } from "@/components/ui/skeleton"
import { Button } from "@/components/ui/button"

export default async function DashboardPage() {
  const [kpis, series] = await Promise.all([getKpis(), getDailyActive()])
  return (
    <div className="space-y-6">
      <PageHeader
        title="Overview"
        description="Acme workspace"
        actions={<Button size="sm" render={<Link href="/projects/new" />}>New project</Button>}
      />
      <KpiRow items={toKpis(kpis)} />
      <div className="grid gap-4 lg:grid-cols-5">
        {/* ChartCard + AreaChart from the previous section, span 3 */}
        <Suspense fallback={<SkeletonCard className="lg:col-span-2" />}>
          <RecentActivity />
        </Suspense>
      </div>
    </div>
  )
}

async function RecentActivity() {
  const events = await getActivity({ limit: 8 })
  return <ActivityFeed className="lg:col-span-2" items={events} />
}

PageHeader renders the page h1, a description and an actions slot. Button accepts a render prop, so the primary action can be a real Link. ActivityFeed takes { id, user, action, time } items and renders an avatar with initials, the actor in medium weight, the action muted, and the time right-aligned.

Empty, loading and error states

A dashboard spends much of its life empty (new accounts), loading (slow queries) or failing (expired tokens). Design all three up front.

Loading

app/(app)/dashboard/loading.tsxtsx
import { Skeleton } from "@/components/ui/skeleton"
import { LoadingTable } from "@/components/ui/loading-table"

export default function Loading() {
  return (
    <div className="space-y-6" role="status" aria-label="Loading dashboard">
      <Skeleton className="h-8 w-48" />
      <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
        {Array.from({ length: 4 }, (_, i) => <Skeleton key={i} className="h-[92px] rounded-xl" />)}
      </div>
      <LoadingTable rows={6} cols={4} />
    </div>
  )
}

Match skeleton sizes to the real content so nothing jumps when data arrives. Skeleton is aria-hidden, so label the wrapper; LoadingTable already carries role="status". Inside a client table that refetches, pass loading to DataTable instead to keep the header in place.

Empty

Use EmptyState for whole sections and EmptyTable inside tables. Both take title, description, actionLabel and onAction. Say what will appear and give the one action that makes it appear, such as "Connect Stripe" or "Invite a teammate". Distinguish a new account ("No customers yet") from a filter with no matches ("No results for these filters"), which DataTable shows by default.

Error

app/(app)/dashboard/error.tsxtsx
"use client"
import { RetryBlock } from "@/components/ui/retry-block"

export default function DashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
  return (
    <RetryBlock
      title="We could not load your dashboard"
      description="This is usually temporary. Try again, and contact support if it keeps happening."
      onRetry={reset}
    />
  )
}

An error.tsx boundary keeps the sidebar and top bar working when one page fails, because it only replaces the segment below the layout.

Start from the blocks

DashboardHome assembles PageHeader, KpiRow, a ChartCard and an ActivityFeed in the 3:2 grid used above. AdminOverview is the org admin variant with an AdminStatStrip for seats, MRR, NPS and churn. Install either, then replace the sample arrays with props fed from your server components. For other screens, the templates show complete apps, and the AI chat guide covers adding an assistant panel.

Dashboard HomeblockA React dashboard home screen with a page header and new project action, a KPI row with deltas, a 30 day active users bar chart and an activity feed.npx shadcn@latest add ui.minidev.pro/r/dashboard-home.jsonAdmin OverviewblockAn admin overview section with a heading and invite action, stat tiles with deltas for seats, MRR, NPS and churn, and a recent activity feed below.npx shadcn@latest add ui.minidev.pro/r/admin-overview.json

Components used

Everything in this guide is free under MIT: app-shell, sidebar-section, topbar, page-header, kpi-row, stat-card, chart-card, area-chart, bar-chart, data-table, activity-feed, empty-state, empty-table, loading-table, skeleton and retry-block. When you need the whole product built rather than the parts, the MiniDev studio builds complete SaaS apps on this kit.

App ShelluiTailwind app shell layout with a fixed width sidebar slot, a topbar slot and a scrolling main area. The sidebar hides below the md breakpoint for mobile.npx shadcn@latest add ui.minidev.pro/r/app-shell.jsonKpi RowuiA responsive row of KPI tiles, up to four across, each with a label, a large value and an up or down delta. Drop it at the top of a SaaS analytics dashboard.npx shadcn@latest add ui.minidev.pro/r/kpi-row.jsonData TableuiReact data table with sortable columns, row selection, a floating bulk actions bar, hover row actions, sticky header, three densities and skeleton loading.npx shadcn@latest add ui.minidev.pro/r/data-table.json
bash
npx shadcn@latest add https://ui.minidev.pro/r/<name>.json

Components used in this guide

Dashboard HomeblockA React dashboard home screen with a page header and new project action, a KPI row with deltas, a 30 day active users bar chart and an activity feed.npx shadcn@latest add ui.minidev.pro/r/dashboard-home.jsonAdmin OverviewblockAn admin overview section with a heading and invite action, stat tiles with deltas for seats, MRR, NPS and churn, and a recent activity feed below.npx shadcn@latest add ui.minidev.pro/r/admin-overview.jsonApp ShelluiTailwind app shell layout with a fixed width sidebar slot, a topbar slot and a scrolling main area. The sidebar hides below the md breakpoint for mobile.npx shadcn@latest add ui.minidev.pro/r/app-shell.jsonSidebaruiApp sidebar shell with a collapsed icon-only width and SidebarNavItem buttons that show icons, truncated labels and an active state. Built with Tailwind.npx shadcn@latest add ui.minidev.pro/r/sidebar.jsonSidebar SectionuiSidebar section that groups nav items under an optional small uppercase heading with tight spacing. Stack several inside the Sidebar to organize app navigation.npx shadcn@latest add ui.minidev.pro/r/sidebar-section.jsonTopbaruiApp top bar that is 56px tall with a title or custom left content and a right slot for actions, avatars or search. Sits above the main content in dashboards.npx shadcn@latest add ui.minidev.pro/r/topbar.jsonPage HeaderuiA Tailwind page title block with an h1, an optional description and a wrapping slot for action buttons on the right, set off by a bottom border.npx shadcn@latest add ui.minidev.pro/r/page-header.jsonKpi RowuiA responsive row of KPI tiles, up to four across, each with a label, a large value and an up or down delta. Drop it at the top of a SaaS analytics dashboard.npx shadcn@latest add ui.minidev.pro/r/kpi-row.json

Frequently asked questions

Is there a free Next.js dashboard template with shadcn-compatible components?

Yes. MiniDev UI's dashboard-home and admin-overview blocks are MIT licensed and install with the shadcn CLI, and every piece inside them (app shell, KPIs, charts, data table) installs on its own too.

Should dashboard pages be server or client components in Next.js?

Keep pages and layouts as server components that fetch data, and make only the interactive leaves client components, such as the nav with active state or a table with selection. Server components can render client components and pass them plain data.

Do I need a chart library for a React admin dashboard?

Not for KPI trends and simple comparisons. Lightweight SVG charts like AreaChart and BarChart cover sparklines, trends and category bars. Reach for a full library when you need zoom, brushing, many axes or very large datasets.

How do I show loading states in a Next.js App Router dashboard?

Add a loading.tsx next to the page for the initial load, wrap slow widgets in Suspense with skeleton fallbacks, and pass loading to client tables when they refetch.

More guides