Inertia.js for the bridge, Pinia for state, Chart.js for metrics, role-based navigation, real-time notifications with Reverb and Echo, dark mode, and mobile-responsive layouts with Tailwind — a complete dashboard that looks like a product, not a practice project.
Most dashboard tutorials stop at a sidebar and a chart with hardcoded data. Ship that to an actual client and the first real questions are the ones the tutorial never answered: what does a viewer-role user see versus an admin, does the revenue chart update when a new sale comes in without a manual refresh, does any of this hold together on a phone, and does the whole thing look broken for the ten seconds between page load and dark mode applying because the theme was read from localStorage after the page already painted white.
This is the version that answers those questions. Not a component gallery — a dashboard with role-scoped navigation driven by real permission data, a metrics chart wired to real backend aggregation, a notification bell that updates over a live WebSocket connection the moment something happens server-side, dark mode that doesn’t flash on load, and layouts that are actually responsive rather than “the desktop layout with breakpoints bolted on afterward.” Built on Inertia as the only bridge between Laravel and Vue, Pinia scoped to the state that’s genuinely cross-page, Chart.js for the metrics themselves, and Reverb for everything real-time.
The Shell — Layout, Not Yet a Page
Everything in this dashboard sits inside one persistent layout, so navigation between pages doesn’t re-mount the sidebar, the header, or the notification listener on every click.
<!-- resources/js/layouts/DashboardLayout.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { usePage } from '@inertiajs/vue3'
import Sidebar from '@/components/dashboard/Sidebar.vue'
import Header from '@/components/dashboard/Header.vue'
import { useThemeStore } from '@/stores/theme'
const page = usePage()
const theme = useThemeStore()
const sidebarOpen = ref(false)
</script>
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors">
<Sidebar
:open="sidebarOpen"
:permissions="page.props.auth.permissions"
@close="sidebarOpen = false"
/>
<div class="lg:pl-64">
<Header @toggle-sidebar="sidebarOpen = !sidebarOpen" />
<main class="p-4 sm:p-6 lg:p-8">
<slot />
</main>
</div>
</div>
)
</template>
<!-- resources/js/pages/Dashboard/Index.vue -->
<script setup lang="ts">
import DashboardLayout from '@/layouts/DashboardLayout.vue'
defineOptions({ layout: DashboardLayout })
</script>
Setting the layout via defineOptions({ layout: ... }) on each page, rather than wrapping every page’s template in <DashboardLayout> manually, keeps the sidebar and header persistent across Inertia navigations — Inertia is aware the layout component didn’t change between two pages that both declare it, so it doesn’t tear down and remount the notification listener or the sidebar’s open/close state on every link click. That persistence matters concretely once the notification bell is wired to a WebSocket connection later in this post — remounting it on every navigation would mean reconnecting the socket on every single page change.
Role-Based Navigation, Driven by Real Data
The sidebar’s contents differ by role, and the permission data driving that has to come from the server, computed once by the same policy layer that actually enforces access — not re-derived as a second, parallel set of role checks written in Vue.
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user(),
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
],
]);
}
<!-- resources/js/components/dashboard/Sidebar.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { Link } from '@inertiajs/vue3'
import {
LayoutDashboard, Users, CreditCard, Settings, BarChart3,
} from 'lucide-vue-next'
const props = defineProps<{ open: boolean; permissions: string[] }>()
defineEmits(['close'])
const navItems = computed(() => [
{ label: 'Overview', icon: LayoutDashboard, href: route('dashboard'), permission: null },
{ label: 'Analytics', icon: BarChart3, href: route('analytics'), permission: 'analytics.view' },
{ label: 'Team', icon: Users, href: route('team.index'), permission: 'team.manage' },
{ label: 'Billing', icon: CreditCard, href: route('billing'), permission: 'billing.manage' },
{ label: 'Settings', icon: Settings, href: route('settings'), permission: null },
].filter(item => item.permission === null || props.permissions.includes(item.permission)))
</script>
<template>
<aside
class="fixed inset-y-0 left-0 z-40 w-64 bg-white dark:bg-gray-900 border-r
border-gray-200 dark:border-gray-800 transition-transform lg:translate-x-0"
:class="open ? 'translate-x-0' : '-translate-x-full'"
>
<nav class="flex flex-col gap-1 p-4">
<Link
v-for="item in navItems"
:key="item.label"
:href="item.href"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium
text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<component :is="item.icon" class="h-5 w-5" />
{{ item.label }}
</Link>
</nav>
</aside>
</template>
Filtering navItems by props.permissions — the same list computed server-side from the user’s actual roles — means a viewer-role user never sees a “Billing” link in the first place, and that’s a UX decision, not a security boundary. The actual boundary is whatever policy the billing.manage permission is checked against in BillingController. If someone reaches /billing directly with the URL, the controller’s own $this->authorize() call is what actually stops them — the sidebar filtering just means they were never shown the door in the first place. Never let the sidebar be the only place that permission is checked.
The Metrics Chart — Real Aggregation, Not Hardcoded Data
// app/Http/Controllers/DashboardController.php
class DashboardController extends Controller
{
public function index(Request $request): Response
{
$range = $request->input('range', '30d');
$days = match ($range) { '7d' => 7, '90d' => 90, default => 30 };
$revenue = Order::query()
->where('created_at', '>=', now()->subDays($days))
->selectRaw('DATE(created_at) as date, SUM(total_cents) as total')
->groupBy('date')
->orderBy('date')
->get();
return Inertia::render('Dashboard/Index', [
'metrics' => [
'revenue' => [
'labels' => $revenue->pluck('date'),
'values' => $revenue->pluck('total')->map(fn ($cents) => $cents / 100),
],
'totalRevenue' => $revenue->sum('total') / 100,
'orderCount' => Order::where('created_at', '>=', now()->subDays($days))->count(),
],
'range' => $range,
]);
}
}
<!-- resources/js/components/dashboard/RevenueChart.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
const props = defineProps<{
labels: string[]
values: number[]
}>()
const canvas = ref<HTMLCanvasElement>()
let chart: Chart | null = null
function render() {
if (!canvas.value) return
chart?.destroy() // always destroy before re-creating — Chart.js doesn't do this for you
chart = new Chart(canvas.value, {
type: 'line',
data: {
labels: props.labels,
datasets: [{
label: 'Revenue',
data: props.values,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.3,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { ticks: { callback: (v) => `$${v}` } },
},
},
})
}
onMounted(render)
watch(() => [props.labels, props.values], render, { deep: true })
onUnmounted(() => chart?.destroy())
</script>
<template>
<div class="h-72">
<canvas ref="canvas" />
</div>
</template>
Two details here that are easy to skip and cause real bugs later. First, chart?.destroy() before every re-render — Chart.js attaches to the canvas element directly and doesn’t automatically clean up a previous instance when new Chart() is called again on the same element, so switching the date range without destroying the old chart first leaves a ghost instance still listening for resize events, still holding a reference to stale data, silently leaking. Second, onUnmounted(() => chart?.destroy()) — the same leak, but on navigation away from the dashboard entirely rather than just a data refresh. Neither of these shows up in a quick demo. Both show up as memory growth and visual glitching after a session where a user has switched date ranges and pages a dozen times.
<!-- resources/js/pages/Dashboard/Index.vue -->
<script setup lang="ts">
import { router } from '@inertiajs/vue3'
import RevenueChart from '@/components/dashboard/RevenueChart.vue'
const props = defineProps<{
metrics: { revenue: { labels: string[]; values: number[] }; totalRevenue: number; orderCount: number }
range: string
}>()
function setRange(range: string) {
router.get(route('dashboard'), { range }, { preserveState: true, preserveScroll: true })
}
</script>
<template>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
<!-- metric cards -->
</div>
<div class="flex gap-2 mb-4">
<button
v-for="r in ['7d', '30d', '90d']"
:key="r"
@click="setRange(r)"
class="px-3 py-1.5 rounded-md text-sm font-medium"
:class="range === r ? 'bg-indigo-600 text-white' : 'bg-gray-100 dark:bg-gray-800'"
>
{{ r }}
</button>
</div>
<div class="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4">
<RevenueChart :labels="metrics.revenue.labels" :values="metrics.revenue.values" />
</div>
</template>
router.get(..., { preserveState: true, preserveScroll: true }) on the date-range switcher is what keeps this feeling like an interactive widget instead of a full page reload — Inertia still makes a real server round trip to get fresh aggregated data (the aggregation genuinely needs to happen server-side, not be recomputed from a large dataset shipped to the client), but preserveState keeps other page-local component state intact across that round trip, and preserveScroll stops the page from jumping back to the top every time someone clicks a date range button.
Real-Time Notifications With Reverb and Echo
The notification bell needs to update the moment something happens on the server — a new order, a team invitation accepted — without the user refreshing. This is genuinely cross-page state, which is exactly the case Pinia is for.
// resources/js/stores/notifications.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface Notification {
id: string
message: string
read: boolean
createdAt: string
}
export const useNotificationStore = defineStore('notifications', () => {
const items = ref<Notification[]>([])
const unreadCount = computed(() => items.value.filter(n => !n.read).length)
function add(notification: Notification) {
items.value.unshift(notification)
}
function markRead(id: string) {
const notification = items.value.find(n => n.id === id)
if (notification) notification.read = true
}
function setInitial(initial: Notification[]) {
items.value = initial
}
return { items, unreadCount, add, markRead, setInitial }
})
// resources/js/composables/useEcho.ts — one shared entry point, imported wherever needed
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
window.Pusher = Pusher
export const echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT,
forceTLS: true,
})
<!-- resources/js/components/dashboard/Header.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { usePage } from '@inertiajs/vue3'
import { Bell } from 'lucide-vue-next'
import { echo } from '@/composables/useEcho'
import { useNotificationStore } from '@/stores/notifications'
defineEmits(['toggleSidebar'])
const page = usePage()
const notifications = useNotificationStore()
const userId = page.props.auth.user.id
let channel: ReturnType<typeof echo.private>
onMounted(() => {
channel = echo.private(`users.${userId}.notifications`)
.listen('.notification.created', (e: { notification: Notification }) => {
notifications.add(e.notification)
})
})
onUnmounted(() => {
echo.leave(`users.${userId}.notifications`)
})
</script>
<template>
<header class="sticky top-0 z-30 bg-white/80 dark:bg-gray-900/80 backdrop-blur
border-b border-gray-200 dark:border-gray-800">
<div class="flex items-center justify-between px-4 py-3">
<button class="lg:hidden" @click="$emit('toggleSidebar')">☰</button>
<div class="relative ml-auto">
<Bell class="h-5 w-5" />
<span
v-if="notifications.unreadCount > 0"
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center
rounded-full bg-red-500 text-[10px] text-white"
>
{{ notifications.unreadCount }}
</span>
</div>
</div>
</header>
</template>
// app/Events/NotificationCreated.php
class NotificationCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public DatabaseNotification $notification) {}
public function broadcastOn(): Channel
{
return new PrivateChannel("users.{$this->notification->notifiable_id}.notifications");
}
public function broadcastAs(): string
{
return 'notification.created';
}
}
// routes/channels.php — private channel authorization
Broadcast::channel('users.{userId}.notifications', function (User $user, int $userId) {
return $user->id === $userId;
});
The private channel authorization in routes/channels.php is the actual security boundary here — without it, the channel name alone (users.{id}.notifications) would be guessable, and Reverb needs to explicitly verify the connecting user actually owns that ID before allowing the subscription. Because Header.vue lives inside the persistent DashboardLayout rather than being remounted per-page, this WebSocket connection is established once per session, not once per page navigation — which is the entire reason the layout persistence decision from the start of this post matters in practice, not just in theory.
Dark Mode — Without the Flash
The naive dark mode implementation reads a theme preference from localStorage inside a Vue component’s onMounted, which means the page paints in light mode first, then flips to dark a moment later once Vue has hydrated — a visible, jarring flash on every load for anyone using dark mode.
<!-- resources/views/app.blade.php — runs before Vue mounts, before first paint -->
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<script>
// Blocking, inline, and first in <head> — this has to run before
// the browser paints anything, which means it can't be inside a
// Vue component's lifecycle at all.
const theme = localStorage.getItem('theme') ??
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', theme === 'dark');
</script>
@vite(['resources/js/app.ts'])
@inertiaHead
</head>
<body>
@inertia
</body>
</html>
// resources/js/stores/theme.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useThemeStore = defineStore('theme', () => {
const isDark = ref(document.documentElement.classList.contains('dark'))
function toggle() {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
return { isDark, toggle }
})
The Pinia store here doesn’t decide the initial theme — it just reads whatever the inline script already applied to <html> before Vue ever mounted, and handles toggling from that point forward. This split matters: the inline blocking script in the Blade layout is the only thing that can run early enough to prevent the flash, because anything inside Vue’s lifecycle — even onBeforeMount — runs after the browser has already painted the initial HTML. Tailwind’s dark: variant works off the dark class on <html>, so once that class is set correctly before first paint, every dark: utility class in every component just works, with no additional wiring needed per component.
Mobile-Responsive Layouts, Actually
“Responsive” as an afterthought usually means the desktop grid with a few breakpoint classes bolted on, and the result technically renders on a phone without actually being usable there — buttons too small to tap accurately, a table that requires horizontal scrolling to read a single row, a sidebar that overlaps content instead of properly collapsing.
<!-- Metric cards: desktop 4-column grid collapses to a scrollable 2-column,
not a single narrow column that pushes everything below the fold -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4">
<MetricCard
v-for="metric in metricCards"
:key="metric.label"
v-bind="metric"
/>
</div>
<!-- A data table that becomes a stacked card list below the sm breakpoint,
instead of forcing horizontal scroll on a table that was never designed
to be read that way on a narrow screen -->
<template>
<table class="hidden sm:table w-full">
<!-- full table, desktop and tablet -->
</table>
<div class="sm:hidden flex flex-col gap-3">
<div
v-for="row in rows"
:key="row.id"
class="rounded-lg border border-gray-200 dark:border-gray-800 p-4"
>
<div class="flex justify-between text-sm font-medium">
<span>{{ row.name }}</span>
<span>{{ row.amount }}</span>
</div>
<div class="text-xs text-gray-500 mt-1">{{ row.date }}</div>
</div>
</div>
</template>
The table-to-card-list pattern is the single highest-impact responsive decision in a data-heavy dashboard — a table genuinely does not translate to a narrow viewport by shrinking, because the information density that makes a table useful on desktop is exactly what makes it unreadable at 375px wide. Rendering two structurally different layouts for the same data, switched with hidden sm:table / sm:hidden, costs a bit more markup than one responsive table, and produces something people can actually use on a phone instead of something that merely doesn’t overflow the viewport.
The sidebar’s mobile behavior matters as much as its desktop behavior. -translate-x-full sliding it fully off-screen below lg, with a backdrop click closing it, rather than a media-query display toggle, keeps the open/close interaction feeling like a real mobile navigation pattern instead of a desktop sidebar that happens to be technically present on a phone screen.
The Complete Shape
resources/js/
layouts/DashboardLayout.vue — persistent shell, mounted once per session
components/dashboard/
Sidebar.vue — role-filtered nav, server-computed permissions
Header.vue — notification bell, Echo listener lives here
RevenueChart.vue — Chart.js, destroy-before-recreate discipline
stores/
notifications.ts — genuinely cross-page, Pinia-appropriate
theme.ts — reflects, doesn't decide, initial theme
composables/useEcho.ts — one shared Echo instance
resources/views/app.blade.php — inline blocking script, theme decided
before first paint, before Vue exists
app/Http/Controllers/DashboardController.php — real aggregation, not
hardcoded chart data
app/Events/NotificationCreated.php — ShouldBroadcast, private channel
routes/channels.php — the actual authorization boundary
The One Rule
Every piece of this dashboard that separates it from a tutorial demo is the same kind of decision: doing the thing that only shows up as a problem under real, sustained use. A chart that leaks because nobody called destroy(). A dark mode flash that only bothers someone using dark mode, every single load, forever. A sidebar link that’s hidden but not actually enforced, waiting for someone to type a URL directly. A responsive table that “works” in the sense of not overflowing while being unusable in the sense of anyone trying to actually read it. None of these fail a five-minute demo. All of them fail a real user, on a real device, in the first week — which is exactly the gap between a component that looks like a dashboard and one that survives being used as a product.
