All posts
tutorialdashboardsaasnext.js

Build a SaaS Dashboard in a Weekend with Gwan

Nimesh FonsekaMay 3, 20265 min read
Build a SaaS Dashboard in a Weekend with Gwan

This is a build log, not a theory post. We're going to wire up a real admin dashboard using Gwan components — the kind you'd ship to a paying customer. By the end you'll have a top navigation bar, a metrics row, a data table with filters and pagination, inline actions with confirmation modals, and toast notifications throughout.

What we're building

A Users page for a SaaS product. Here's exactly what you'll end up with:

Users

Manage your team and permissions.

Total Users

9,430

8% vs last month

Revenue

$48,295

12% vs last month

New Orders

1,284

3% vs last month

Plan usage

78%

78%

7,800 / 10,000

Team

AM
BC
CS
DP
+2
NameEmailRoleStatus
AM
Alice Martin
alice@company.comAdminActive
BC
Bob Chen
bob@company.comViewerActive
CS
Clara Singh
clara@company.comEditorSuspended
DP
David Park
david@company.comViewerActive

Setup

npx create-next-app@latest my-dashboard --typescript --tailwind --app
cd my-dashboard
npm install gwan-design-system @tanstack/react-query

Follow the Getting Started guide to configure Tailwind and your theme tokens.

1. The page shell with MenuBar

MenuBar replaces the old vertical NavBar for dashboards — horizontal, sticky, supports avatar and overflow.

// src/app/dashboard/users/page.tsx
import UsersTemplate from "@/templates/users";
 
export default function UsersPage() {
  return <UsersTemplate />;
}
// src/templates/users/index.tsx
"use client";
 
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
  MenuBar,
  Snackbar,
  SNACK_BAR_TYPE,
  MENU_BAR_VARIANT,
  AVATAR_VARIANT,
  AVATAR_SIZE,
  AVATAR_LABEL_POSITION,
} from "gwan-design-system";
import {
  DashboardSVG,
  UsersSVG,
  OrdersSVG,
  SettingsSVG,
} from "gwan-design-system";
import UsersContent from "./content";
 
const menuItems = [
  {
    title: "Dashboard",
    icon: <DashboardSVG />,
    route: "/dashboard",
    isActive: false,
    isDivider: false,
  },
  {
    title: "Users",
    icon: <UsersSVG />,
    route: "/dashboard/users",
    isActive: true,
    isDivider: false,
  },
  {
    title: "Orders",
    icon: <OrdersSVG />,
    route: "/dashboard/orders",
    isActive: false,
    isDivider: false,
  },
  {
    title: "Settings",
    icon: <SettingsSVG />,
    route: "/dashboard/settings",
    isActive: false,
    isDivider: false,
  },
];
 
export default function UsersTemplate() {
  const router = useRouter();
  const [snack, setSnack] = useState<{ message: string; type: string } | null>(
    null,
  );
 
  return (
    <div className="min-h-screen bg-background">
      <MenuBar
        menuItems={menuItems}
        logoShort="/logo-short.png"
        logoLong="/logo.png"
        variant={MENU_BAR_VARIANT.DEFAULT}
        sticky
        isAvatarVisible
        avatarName="John Doe"
        avatarEmail="john@company.com"
        avatarImage="/avatar.png"
        avatarType={AVATAR_VARIANT.IMAGE_WITH_FULL}
        avatarSize={AVATAR_SIZE.SM}
        avatarLabelPosition={AVATAR_LABEL_POSITION.RIGHT}
        onNavigate={(route) => router.push(route)}
      />
 
      <main className="px-6 py-8 max-w-7xl mx-auto">
        <div className="mb-8">
          <h2 className="text-lg font-black text-foreground">Users</h2>
          <p className="text-xs text-muted-fg mt-0.5">
            Manage your team and permissions.
          </p>
        </div>
 
        <UsersContent
          onAction={(msg, type) => setSnack({ message: msg, type })}
        />
      </main>
 
      {snack && (
        <Snackbar
          message={snack.message}
          type={snack.type as never}
          onClose={() => setSnack(null)}
        />
      )}
    </div>
  );
}

2. Metrics row with StatCard and CircularProgress

The top of every good dashboard is a metrics row. StatCard handles the KPI tiles; CircularProgress works great for a single ring metric alongside them.

// src/templates/users/metrics.tsx
import {
  StatCard,
  CircularProgress,
  STAT_TREND,
  CIRCULAR_PROGRESS_TYPE,
  CIRCULAR_PROGRESS_SIZE,
} from "gwan-design-system";
import { UsersSVG, MoneySVG, OrdersSVG } from "gwan-design-system";
 
export default function Metrics() {
  return (
    <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
      <StatCard
        label="Total Users"
        value="9,430"
        trend={STAT_TREND.UP}
        trendLabel="8% vs last month"
        icon={<UsersSVG />}
      />
      <StatCard
        label="Revenue"
        value="$48,295"
        trend={STAT_TREND.UP}
        trendLabel="12% vs last month"
        icon={<MoneySVG />}
      />
      <StatCard
        label="New Orders"
        value="1,284"
        trend={STAT_TREND.DOWN}
        trendLabel="3% vs last month"
        icon={<OrdersSVG />}
      />
 
      <div className="bg-surface border border-border rounded-xl p-4 flex flex-col gap-2">
        <p className="text-xs text-muted-fg">Plan usage</p>
        <div className="flex items-center gap-3">
          <CircularProgress
            value={78}
            size={CIRCULAR_PROGRESS_SIZE.SM}
            type={CIRCULAR_PROGRESS_TYPE.SUCCESS}
          />
          <div>
            <p className="text-sm font-bold text-foreground">78%</p>
            <p className="text-[10px] text-muted-fg">7,800 / 10,000</p>
          </div>
        </div>
      </div>
    </div>
  );
}

3. Team members with AvatarGroup

Show who's on the team with a compact stacked avatar row — overflow names appear in a tooltip on hover.

import { AvatarGroup, AVATAR_SIZE, TOOLTIP_POSITION } from "gwan-design-system";
 
const teamMembers = [
  { name: "Alice Martin", email: "alice@company.com", image: "/alice.png" },
  { name: "Bob Chen", email: "bob@company.com" },
  { name: "Clara Singh", email: "clara@company.com", image: "/clara.png" },
  { name: "David Park", email: "david@company.com" },
  { name: "Eva Torres", email: "eva@company.com", image: "/eva.png" },
  { name: "Frank Liu", email: "frank@company.com" },
];
 
export default function TeamRow() {
  return (
    <div className="flex items-center gap-3">
      <p className="text-xs text-muted-fg">Team</p>
      <AvatarGroup
        items={teamMembers}
        max={4}
        size={AVATAR_SIZE.SM}
        overflowTooltipPosition={TOOLTIP_POSITION.TOP}
      />
    </div>
  );
}

4. The data table

// src/templates/users/table.tsx
import {
  Table,
  Badge,
  Avatar,
  BADGE_TYPE,
  AVATAR_VARIANT,
  AVATAR_SIZE,
} from "gwan-design-system";
import type { ITableColumn } from "gwan-design-system";
 
type User = {
  name: string;
  email: string;
  role: string;
  status: string;
};
 
const users: User[] = [
  {
    name: "Alice Martin",
    email: "alice@company.com",
    role: "Admin",
    status: "Active",
  },
  {
    name: "Bob Chen",
    email: "bob@company.com",
    role: "Viewer",
    status: "Active",
  },
  {
    name: "Clara Singh",
    email: "clara@company.com",
    role: "Editor",
    status: "Suspended",
  },
  {
    name: "David Park",
    email: "david@company.com",
    role: "Viewer",
    status: "Active",
  },
];
 
const statusBadge: Record<string, BADGE_TYPE> = {
  Active: BADGE_TYPE.SUCCESS,
  Suspended: BADGE_TYPE.DANGER,
};
 
const columns: ITableColumn[] = [
  {
    header: "Name",
    render: (user: User) => (
      <div className="flex items-center gap-2">
        <Avatar
          name={user.name}
          email={user.email}
          variant={AVATAR_VARIANT.INITIALS_ONLY}
          size={AVATAR_SIZE.XS}
        />
        <span className="text-sm font-medium text-foreground">{user.name}</span>
      </div>
    ),
  },
  {
    header: "Email",
    render: (user: User) => (
      <span className="text-sm text-muted-fg">{user.email}</span>
    ),
  },
  {
    header: "Role",
    render: (user: User) => (
      <span className="text-sm text-muted-fg">{user.role}</span>
    ),
  },
  {
    header: "Status",
    render: (user: User) => (
      <Badge type={statusBadge[user.status]} label={user.status} />
    ),
  },
];
 
export default function UsersTable() {
  return <Table columns={columns} data={users} bordered />;
}

5. Putting it all together

// src/templates/users/content.tsx
import Metrics from "./metrics";
import TeamRow from "./team";
import UsersTable from "./table";
 
export default function UsersContent() {
  return (
    <>
      <Metrics />
      <TeamRow />
      <UsersTable />
    </>
  );
}

What this covers

SectionComponents used
Top navigationMenuBar with sticky, avatar, active item
Metrics rowStatCard (×3) + CircularProgress
Team stripAvatarGroup with overflow tooltip
Data tableTable with Avatar + Badge in render columns

What's next

  • Wire up real data using @tanstack/react-query
  • Add filtering with FilterDropdown
  • Add pagination with Pagination
  • Add a delete confirmation using Modal + Button
  • Add toast feedback with Snackbar
  • Add a "Create user" slide-in form using Drawer
  • Export to CSV with Button + CsvSVG icon

Share this article

LinkedInX / Twitter

Written by

Nimesh Fonseka

More posts →