Dashboard widgets

Stats, charts, activity feed.

9 minmedium

Logged-in user landing surface. Three sections: stat cards across the top, a chart in the middle, an activity feed on the side. The Grit scaffold ships these as starter widgets — this lesson covers how they wire up.

The dashboard shell

apps/web/app/(app)/dashboard/page.tsx
import { StatCards } from './stat-cards'
import { RevenueChart } from './revenue-chart'
import { ActivityFeed } from './activity-feed'
import { apiFetch } from '@/lib/api'
export default async function DashboardPage() {
const stats = await apiFetch('/api/dashboard/stats').then((r) => r.json())
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-3">
<StatCards stats={stats.data} />
</div>
<div className="lg:col-span-2">
<RevenueChart />
</div>
<div>
<ActivityFeed />
</div>
</div>
)
}

Server component — fetches stats on the server, passes to the client widgets that need interactivity (chart hover, feed polling).

Stat cards

apps/web/app/(app)/dashboard/stat-cards.tsx
import { formatCurrency } from '@workspace/shared/utils/currency'
interface Stats {
users: number
revenue: number
orders: number
conversion: number
}
export function StatCards({ stats }: { stats: Stats }) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card label="Users" value={stats.users.toLocaleString()} />
<Card label="Revenue" value={formatCurrency(stats.revenue)} />
<Card label="Orders" value={stats.orders.toLocaleString()} />
<Card label="Conversion" value={(stats.conversion * 100).toFixed(1) + '%'} />
</div>
)
}
function Card({ label, value }) {
return (
<div className="rounded-xl border p-4 bg-card">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold mt-1">{value}</p>
</div>
)
}

Pure server component. No JS shipped to the browser for the cards.

The chart — Recharts (client component)

apps/web/app/(app)/dashboard/revenue-chart.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
export function RevenueChart() {
const { data } = useQuery({
queryKey: ['revenue-30d'],
queryFn: () => fetch('/api/dashboard/revenue/30d').then(r => r.json()),
})
return (
<div className="rounded-xl border p-4 bg-card">
<h3 className="font-semibold mb-3">Revenue, last 30 days</h3>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={data?.data ?? []}>
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="revenue" stroke="#6c5ce7" />
</LineChart>
</ResponsiveContainer>
</div>
)
}

Recharts is the practical pick — small bundle, declarative, composable. Charts are inherently interactive so they live in client components.

Split server vs. client by interactivity, not page. The page itself is server; chart is client; cards are server. Each piece ships only as much JS as it needs.

The activity feed

apps/web/app/(app)/dashboard/activity-feed.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
export function ActivityFeed() {
const { data } = useQuery({
queryKey: ['activity'],
queryFn: () => fetch('/api/activity?limit=10').then(r => r.json()),
refetchInterval: 30_000, // poll every 30s for fresh events
})
return (
<div className="rounded-xl border p-4 bg-card">
<h3 className="font-semibold mb-3">Recent activity</h3>
<ul className="space-y-2 text-sm">
{(data?.data ?? []).map((a) => (
<li key={a.id}>
<span className="text-muted-foreground">{a.created_at}</span>{' '}
{a.event}
</li>
))}
</ul>
</div>
)
}

Polls every 30 seconds for new events. For real-time, swap polling for the Grit WebSocket plugin (covered in the plugins course).

Quick check

Your dashboard initially loads fast but slows over time as users add data. Which widget is the most likely culprit?

Try it

For chapter 3's assignment, build the dashboard:

  1. Sign up a new user (from last lesson).
  2. Add 3 stat cards backed by real API endpoints.
  3. Add the activity feed pulling from /api/activity.
  4. Screenshot the dashboard. Paste it in notes.md.

What's next

Chapter 4 — The Admin Panel. Filament-style CRUD pages built from one defineResource() call.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub