Building a dashboard with Vue for Vue & Nuxt developers
Building a dashboard with Vue for Vue & Nuxt developers requires balancing performance with feature richness when you're handling real-time data updates and user interactions at scale. We've seen dashboards slow to a crawl because of poor caching strategy or rendering hundreds of rows without virtualization. This guide walks through the architectural patterns and libraries we use in production to keep dashboards responsive, from sidebar navigation through data fetching, charting, and table rendering.
Layout: Sidebar Navigation with Nested Routes
A typical dashboard starts with a persistent sidebar and a main content area where routes drive which widgets or pages appear. In Nuxt, this is straightforward: define a layout component, nest your pages, and use dynamic route parameters to load different dashboards or metrics views.
Here's a minimal sidebar layout that works for both Vue Router and Nuxt:
// layouts/dashboard.vue
<template>
<div class="dashboard-container">
<aside class="sidebar">
<nav>
<ul>
<li>
<NuxtLink to="/dashboard/overview">Overview</NuxtLink>
</li>
<li>
<NuxtLink to="/dashboard/users">Users</NuxtLink>
</li>
<li>
<NuxtLink to="/dashboard/analytics">Analytics</NuxtLink>
</li>
</ul>
</nav>
</aside>
<main class="content">
<slot />
</main>
</div>
</template>
<style scoped>
.dashboard-container {
display: grid;
grid-template-columns: 250px 1fr;
min-height: 100vh;
}
.sidebar {
background: #f5f5f5;
border-right: 1px solid #e0e0e0;
padding: 1rem;
overflow-y: auto;
}
.content {
padding: 2rem;
overflow-y: auto;
}
</style>For Nuxt, pages automatically nest under this layout. In plain Vue, wrap your router with a layout component that checks the current route. The key is keeping the sidebar persistent while only the main slot content changes. Consider using active route indicators or collapsible menu sections for larger navigation trees.
Data: TanStack Query for Caching and Synchronization
Dashboards live or die by their data layer. TanStack Query (formerly React Query, now framework-agnostic) handles caching, background refetching, and stale-while-revalidate patterns beautifully. Without it, every route change triggers a fresh API call and flickering spinners.
Set up TanStack Query in your main entry point:
// main.ts or nuxt.config.ts
import { VueQueryPlugin } from '@tanstack/vue-query'
app.use(VueQueryPlugin, {
queryClientConfig: {
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
retry: 1,
},
},
},
})Now use the useQuery composable to fetch dashboard data:
// composables/useDashboardMetrics.ts
import { useQuery } from '@tanstack/vue-query'
export function useDashboardMetrics(dateRange: Ref<string>) {
return useQuery({
queryKey: ['dashboard-metrics', dateRange.value],
queryFn: async () => {
const res = await fetch(
`/api/metrics?range=${dateRange.value}`
)
return res.json()
},
enabled: computed(() => !!dateRange.value),
})
}
// In your dashboard component:
const dateRange = ref('7d')
const { data: metrics, isLoading, error } = useDashboardMetrics(dateRange)TanStack Query automatically handles refetching when the component remounts or the window regains focus. If a user switches to another dashboard and returns, the cached data serves instantly unless it's stale. For APIs that support conditional requests, add ETag headers to minimize payload size during background refetches.
Charts: Rendering with ECharts or Chart.js
Dashboards without charts are just tables. ECharts excels at large datasets and interactive features; Chart.js is lighter for simpler needs. We typically reach for ECharts in e-commerce and analytics dashboards where drill-down interactions matter.
Install and wrap ECharts:
// components/RevenueChart.vue
<template>
<div ref="chartContainer" style="width: 100%; height: 400px"></div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import * as echarts from 'echarts'
interface ChartData {
dates: string[]
revenue: number[]
}
const props = defineProps<{
data: ChartData
}>()
const chartContainer = ref<HTMLDivElement | null>(null)
let chartInstance: echarts.ECharts | null = null
onMounted(() => {
if (chartContainer.value) {
chartInstance = echarts.init(chartContainer.value)
renderChart()
}
})
function renderChart() {
if (!chartInstance) return
const option = {
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: props.data.dates,
},
yAxis: {
type: 'value',
},
series: [
{
data: props.data.revenue,
type: 'line',
smooth: true,
},
],
}
chartInstance.setOption(option)
}
watch(() => props.data, renderChart, { deep: true })
onBeforeUnmount(() => {
chartInstance?.dispose()
})
</script>Use the chart component inside your dashboard:
const { data: metricsData } = useDashboardMetrics(dateRange)
<RevenueChart :data="metricsData.value?.revenue || { dates: [], revenue: [] }" />ECharts handles resizing automatically, but call chartInstance.resize() if your container dimensions change programmatically. Memoize chart instances to avoid recreating them on every render cycle.
Tables: Virtualization for Large Datasets
Rendering 10,000 rows crashes browsers. Virtualization renders only visible rows, slashing DOM nodes and boosting scroll performance. Libraries like vue-virtual-scroller or TanStack's react-virtual (which has Vue adapters) solve this elegantly.
Here's a virtualized table with TanStack Table and vue-virtual-scroller:
// components/UsersTable.vue
<template>
<div class="table-wrapper">
<DynamicScroller
:items="rows"
:item-size="50"
key-field="id"
v-slot="{ item }"
>
<div class="table-row">
<div class="cell">{{ item.id }}</div>
<div class="cell">{{ item.email }}</div>
<div class="cell">{{ item.status }}</div>
</div>
</DynamicScroller>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { DynamicScroller } from 'vue-virtual-scroller'
const { data } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('/api/users?limit=10000')
return res.json()
},
})
const rows = computed(() => data.value?.users || [])
</script>
<style scoped>
.table-wrapper {
height: 600px;
overflow: hidden;
}
.table-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
border-bottom: 1px solid #e0e0e0;
padding: 0.5rem;
}
.cell {
overflow: hidden;
text-overflow: ellipsis;
}
</style>For sorting and filtering on large datasets, push operations to the server. Use search parameters in your query key and re-fetch when they change. If you need client-side sorting, compute it after data arrives but before rendering, and update the query cache instead of re-fetching.
Common Mistakes
- Over-fetching without caching: Every route change re-queries the same data. Use TanStack Query's query keys and stale times to avoid redundant requests.
- Rendering all rows at once: Virtualization is non-negotiable beyond a few hundred rows. Test your tables with realistic data volumes early.
- Synchronous chart resizing: Calling
chart.resize()on every window resize event without debouncing hammers performance. Debounce window listeners and useResizeObserverfor container changes. - Nested query dependencies: Avoid chaining queries where one fetch waits for another to complete if you can fetch in parallel. Use
enabledconditionally only when truly necessary. - Memory leaks in chart disposal: Always call
chartInstance.dispose()before unmounting or recreating instances, especially in SPAs where components mount and unmount frequently.
Key Takeaways
- Persist your sidebar layout using a wrapper component; only swap the main content area as routes change.
- Implement TanStack Query for intelligent caching and background refetches to eliminate redundant API calls.
- Choose ECharts for rich, interactive visualizations or Chart.js for lightweight needs; always dispose chart instances on unmount.
- Virtualize tables rendering more than a few hundred rows using libraries like vue-virtual-scroller to maintain 60fps scroll performance.
- Push filtering, sorting, and pagination to your API; use query keys to manage client-side cache invalidation when filters change.
- Profile your dashboard in production: monitor network tab for duplicate requests, DevTools for memory growth, and Performance tab for long tasks.






Comments
Loading comments…