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%
7,800 / 10,000
Team
| Name | Role | Status | |
|---|---|---|---|
AM | alice@company.com | Admin | Active |
BC | bob@company.com | Viewer | Active |
CS | clara@company.com | Editor | Suspended |
DP | david@company.com | Viewer | Active |
Setup
npx create-next-app@latest my-dashboard --typescript --tailwind --app
cd my-dashboard
npm install gwan-design-system @tanstack/react-queryFollow 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
| Section | Components used |
|---|---|
| Top navigation | MenuBar with sticky, avatar, active item |
| Metrics row | StatCard (×3) + CircularProgress |
| Team strip | AvatarGroup with overflow tooltip |
| Data table | Table 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+CsvSVGicon
Share this article
Written by
Nimesh Fonseka