Building a dashboard with SvelteKit gives us a structured approach to creating app-like interfaces that feel native and responsive. We're going to walk through the essentials: layout hierarchies, authentication guards, real-time data updates, and visualization—all the pieces that make dashboards production-ready. By the end of this article, you'll have a blueprint for shipping a dashboard that scales.
Layout — Sidebar layout with nested routes
A dashboard lives or dies by its navigation structure. Most of the dashboards we build follow the same pattern: a persistent sidebar on the left, main content area that changes, and often a top bar for user info or quick actions. SvelteKit's layout system lets us define this once and reuse it across all dashboard pages.
We start by creating a layout route. In SvelteKit, any +layout.svelte file establishes a layout boundary, and all child routes render within its <slot />.
// src/routes/(dashboard)/+layout.svelte
<script>
import Sidebar from '$lib/components/Sidebar.svelte';
import TopBar from '$lib/components/TopBar.svelte';
</script>
<div class="dashboard-container">
<Sidebar />
<div class="main-content">
<TopBar />
<div class="page-content">
<slot />
</div>
</div>
</div>
<style>
.dashboard-container {
display: flex;
height: 100vh;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
}
.page-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
}
</style>The key insight here is the grouping with parentheses: (dashboard). This is a route group—it doesn't appear in the URL but organizes our file structure. Any route inside this group gets the layout. So src/routes/(dashboard)/overview/+page.svelte becomes /overview in the browser, not /dashboard/overview.
For the sidebar, we typically use a navigation array and conditionals to show active states:
// src/lib/components/Sidebar.svelte
<script>
import { page } from '$app/stores';
const navItems = [
{ label: 'Overview', href: '/overview', icon: 'home' },
{ label: 'Users', href: '/users', icon: 'users' },
{ label: 'Analytics', href: '/analytics', icon: 'chart' },
{ label: 'Settings', href: '/settings', icon: 'gear' },
];
</script>
<nav class="sidebar">
<div class="logo">MyApp</div>
<ul>
{#each navItems as item}
<li>
<a
href={item.href}
class:active={$page.url.pathname === item.href}
>
{item.label}
</a>
</li>
{/each}
</ul>
</nav>
<style>
.sidebar {
width: 250px;
background: #1a1a1a;
padding: 1.5rem 0;
}
.active {
background: #0066cc;
}
</style>This approach keeps the sidebar rendering consistently across all dashboard pages without repetition.
Auth — Protected layout server load
We can't have users accessing the dashboard without authentication. SvelteKit's server load functions give us a perfect place to enforce this. A server load runs before the page renders, giving us a chance to redirect unauthenticated users.
// src/routes/(dashboard)/+layout.server.ts
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
throw redirect(302, '/login');
}
return {
user: locals.user,
};
};
The locals object comes from your authentication middleware (typically set in hooks.server.ts). When a user isn't logged in, we throw a redirect. This happens server-side before the page hydrates, so users never see the dashboard layout flashing before getting redirected.
In your hooks file, you'd set locals.user from a session token or JWT:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('auth_token');
if (token) {
const user = await verifyToken(token);
event.locals.user = user;
}
return resolve(event);
};
This pattern ensures every page within the dashboard layout is automatically protected. No need to repeat auth checks on individual pages.
Data — Server loads and form actions for CRUD
Real dashboards need to display and modify data. We use server loads to fetch data and form actions to handle submissions. This keeps data fetching on the server where it's secure and performant.
Let's say we're building a users dashboard. The load function fetches the user list:
// src/routes/(dashboard)/users/+page.server.ts
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ locals, fetch }) => {
const response = await fetch('/api/users', {
headers: {
'Authorization': `Bearer ${locals.user.token}`,
},
});
const users = await response.json();
return { users };
};
export const actions: Actions = {
delete: async ({ request, locals }) => {
const formData = await request.formData();
const userId = formData.get('userId');
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${locals.user.token}`,
},
});
if (!response.ok) {
return { error: 'Failed to delete user' };
}
return { success: true };
},
};
On the page itself, we display the data and use form actions for mutations:
// src/routes/(dashboard)/users/+page.svelte
<script>
import { enhance } from '$app/forms';
export let data;
export let form;
</script>
<h1>Users</h1>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each data.users as user (user.id)}
<tr>
<td>{user.name}</td>
<td>{user.email}</td>
<td>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="userId" value={user.id} />
<button type="submit">Delete</button>
</form>
</td>
</tr>
{/each}
</tbody>
</table>
The use:enhance directive intercepts form submissions, shows loading states, and updates data without full page reloads. Real dashboards often have multiple data sources, so consider organizing server load calls with Promise.all to reduce waterfall requests:
export const load: PageServerLoad = async ({ locals, fetch }) => {
const [usersRes, statsRes, ordersRes] = await Promise.all([
fetch('/api/users', { headers: { 'Authorization': `Bearer ${locals.user.token}` } }),
fetch('/api/stats', { headers: { 'Authorization': `Bearer ${locals.user.token}` } }),
fetch('/api/orders', { headers: { 'Authorization': `Bearer ${locals.user.token}` } }),
]);
return {
users: await usersRes.json(),
stats: await statsRes.json(),
orders: await ordersRes.json(),
};
};
Charts — LayerChart or Chart.js integrations
Numbers alone don't tell the story. Charts transform raw data into insights. For SvelteKit dashboards, we typically reach for either a lightweight SVG-based charting library or something like Chart.js for broader browser support.
LayerChart (also called Layerchart) is excellent for Svelte because it embraces component composition:
// src/routes/(dashboard)/analytics/+page.svelte
<script>
import { Chart, Svg, Axis, Bar, Tooltip } from 'layerchart';
export let data;
</script>
<div class="chart-container">
<Chart {data: data.dailyRevenue} let:width let:height>
<Svg {width} {height}>
<Axis placement="left" />
<Axis placement="bottom" />
<Bar x="date" y="revenue" />
<Tooltip let:data>
<div class="tooltip">
<strong>{data.date}</strong>
<p>Revenue: ${data.revenue}</p>
</div>
</Tooltip>
</Svg>
</Chart>
</div>
<style>
.chart-container {
width: 100%;
height: 400px;
}
</style>
If you need wider browser compatibility or more chart types out of the box, Chart.js is rock-solid. We use a wrapper component to manage the canvas lifecycle:
// src/lib/components/RevenueChart.svelte
<script>
import { onMount } from 'svelte';
import Chart from 'chart.js/auto';
export let data;
let canvas;
onMount(() => {
const ctx = canvas.getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => d.date),
datasets: [{
label: 'Revenue',
data: data.map(d => d.amount),
borderColor: '#0066cc',
backgroundColor: 'rgba(0, 102, 204, 0.1)',
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
},
});
});
</script>
<canvas bind:this={canvas}></canvas>
The key with charts on dashboards is keeping them responsive. Always wrap them in containers with fixed dimensions and use responsive: true in Chart.js config. Real dashboards often fetch updated chart data on a timer or via WebSocket for live updates.
Common mistakes
- Fetching data in +page.svelte instead of +page.server.ts. Client-side fetches expose API endpoints and tokens. Always fetch on the server and pass data through props.
- Not using route groups. Dashboard URLs can get messy without organization. Use
(dashboard)and nested groups to keep structure clean. - Forgetting to handle loading states. Users need feedback. Use
use:enhanceto show spinners or disable buttons during submissions. - Rendering huge datasets in tables. Pagination or virtual scrolling is essential when displaying 1000+ rows. Without it, the DOM gets bloated and performance tanks.
- Mixing authentication checks across pages. Define auth once in the root layout, not on every page. This prevents security gaps and reduces duplication.
- Over-fetching chart data. Charts often don't need full precision. Aggregate or sample data on the server before sending to the client.
Key takeaways
- Use layout grouping
(dashboard)to organize routes and apply a shared layout without polluting your URL structure. - Protect the entire dashboard with a single server load function in the root layout. Redirect unauthenticated users before any page renders.
- Fetch data server-side and handle mutations with form actions. This keeps your API secure and your data consistent.
- Promise.all your server loads to avoid waterfall requests when fetching multiple data sources.
- Choose LayerChart for composable, Svelte-native charts or Chart.js for wider feature support and browser compatibility.
- Always wrap charts in containers with fixed dimensions and use responsive options to avoid layout jank.
- Use
use:enhanceon forms to intercept submissions and provide real-time feedback without full page reloads.






Comments
Loading comments…