Back to sh0
sh0

Building a Production Dashboard with Svelte 5 in 48 Hours

How we built sh0's production dashboard -- dark/light themes, 5-language i18n, real-time WebSocket logs, and 7 core pages -- using Svelte 5 runes and TailwindCSS 4 in 48 hours.

Thales & Claude | March 25, 2026 10 min sh0
sveltesvelte-5dashboardtailwindcsstypescriptuifrontend

Every PaaS has a dashboard. Most of them look like they were built in 2018 and never updated. We wanted ours to feel like a product someone would actually enjoy using -- dark mode, five languages, real-time logs, responsive down to mobile -- and we wanted it built into the Rust binary so that sh0 ships as a single executable with zero external dependencies.

On March 12, 2026, we went from an empty dashboard/ folder to a fully functional SvelteKit SPA with 7 pages, 8 shared UI components, an i18n system covering five languages, and a typed API client that handles authentication, error states, and WebSocket reconnection. Then we embedded the entire thing into our Rust binary using include_dir. Forty-eight hours later, every page had real data.

This is how Phases 12, 13, and 14 unfolded -- the scaffolding, the core pages, and the extended pages that turned sh0 from a CLI-only tool into a platform with a face.

Why Svelte 5 and Not React

The decision took about thirty seconds. We needed a framework that compiled to static assets (no server runtime), produced small bundles (the binary should not gain 3 MB of JavaScript), and let us move fast without boilerplate.

Svelte 5 was the clear winner. Runes ($state, $derived, $effect, $props) eliminated the store ceremony that React demands. No useState / useEffect / useCallback / useMemo dance. No dependency array bugs. Just declare your state and use it.

SvelteKit 2 with adapter-static gave us file-based routing and a build step that outputs plain HTML, CSS, and JavaScript -- exactly what we needed to embed into a Rust binary.

// svelte.config.js
import adapter from '@sveltejs/adapter-static';

export default { kit: { adapter: adapter({ fallback: 'index.html' // SPA mode: all routes served by index.html }) } }; ```

The fallback: 'index.html' is critical. Since our Rust server serves the dashboard as static files, every route needs to resolve to the same index.html so that SvelteKit's client-side router can take over.

Phase 12: The Scaffold

Phase 12 ran in parallel with Phase 11 (the backup engine). Two agent teams, zero file overlap. While the backup crate was being written in Rust, the dashboard was being scaffolded in TypeScript.

The scaffold delivered eight things in a single session:

1. TailwindCSS 4 with custom theming. We defined sh0's brand colors as CSS custom properties, then referenced them in Tailwind's config. Dark and light themes use the same class names -- only the variable values change.

/* app.css */
:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f8fafc;
  --text-primary: #0f172a;
  --text-secondary: #475569;
  --border: #e2e8f0;
  --accent: #6366f1;
}

.dark { --bg-primary: #0f172a; --bg-secondary: #1e293b; --text-primary: #f1f5f9; --text-secondary: #94a3b8; --border: #334155; --accent: #818cf8; } ```

Every component uses bg-[var(--bg-primary)] and text-[var(--text-primary)]. No conditional classes. No dark: prefix soup. One system, and it works.

2. The i18n system. Five languages from day one: English, French, Spanish, Portuguese, and Kiswahili. We will cover the reasoning in a dedicated article, but the implementation was straightforward: a t() function backed by per-locale TypeScript objects, with a locale store persisted to localStorage.

3. The auth store and login flow. A Svelte store holds the JWT token and user profile. Every protected route checks this store; if empty, redirect to /login. The login page supports both password and TOTP flows.

4. The API client. A single api.ts module that wraps fetch with automatic Bearer token injection, JSON parsing, and 401 redirect. If the server returns a 401, the client clears the auth store and sends the user back to login -- no stale session states.

async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
  const token = get(authStore).token;
  const res = await fetch(`/api/v1${path}`, {
    method,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });

if (res.status === 401) { authStore.set({ token: null, user: null }); goto('/login'); throw new Error('Unauthorized'); }

const json = await res.json(); return json.data as T; } ```

5. WebSocket client with auto-reconnect. Exponential backoff starting at 1 second, capping at 30 seconds. The WebSocket is used for real-time log streaming, and we knew from experience that connections drop -- especially on African networks where connectivity can be intermittent.

6. Eight shared UI components. Button, Input, Card, Badge, Modal, Spinner, EmptyState, and Toast. Each uses Svelte 5's $props() rune and is fully typed.

7. Responsive sidebar layout. A collapsible sidebar with icon + text navigation, a mobile hamburger menu, and an auth guard that wraps the entire (app) route group.

8. Seven placeholder pages. Dashboard, Apps, Databases, Backups, Monitoring, Templates, and Settings -- each with just an i18n heading, ready to be replaced with real content.

Phase 13: Core Pages Come Alive

The next session replaced the placeholders with functional pages. This was the sprint where the dashboard stopped being a skeleton and started being useful.

The Dashboard Overview

The home page shows four stat cards (total apps, databases, active deployments, system status), a recent deployments list, and quick-action buttons. Every number comes from the API, fetched on mount with a Svelte 5 $effect.

The App List

A card grid with search, filtering, and a "Create App" modal. Each card shows the app name, status dot, last deployment timestamp, and a link to the detail page. The search is client-side with a 150ms debounce -- fast enough that it feels instant, slow enough that it does not fire on every keystroke.

The App Detail: Six Tabs

This was the most complex page. A single route (/apps/[id]/+page.svelte) renders six tabs: Overview, Deployments, Logs, Domains, Environment, and Settings. Each tab is a dedicated component.

The tab system uses Svelte 5 reactivity elegantly:

<script lang="ts">
  import Tabs from '$lib/components/ui/Tabs.svelte';
  import AppOverview from '$lib/components/app/AppOverview.svelte';
  import LogViewer from '$lib/components/app/LogViewer.svelte';
  // ... other imports

let activeTab = $state('overview'); const tabs = [ { id: 'overview', label: t('tabs.overview') }, { id: 'deployments', label: t('tabs.deployments') }, { id: 'logs', label: t('tabs.logs') }, { id: 'domains', label: t('tabs.domains') }, { id: 'env', label: t('tabs.environment') }, { id: 'settings', label: t('tabs.settings') } ];

{#if activeTab === 'overview'} {:else if activeTab === 'logs'} {/if} ```

No router gymnastics. No lazy-loading complexity. Just conditional rendering driven by a single $state variable. Svelte 5 makes this pattern nearly free because unmounted components are truly destroyed -- no memory leaks from forgotten subscriptions.

The LogViewer

The LogViewer component deserves special mention. It opens a WebSocket connection to /api/v1/apps/:id/logs/stream, renders incoming lines in a monospaced container with auto-scroll, and maintains a 1,000-line buffer to prevent memory growth. The auto-scroll behavior is thoughtful: if the user has scrolled up to read older logs, new lines arrive silently without yanking the viewport. Scroll to the bottom, and auto-scroll re-engages.

Backend: Env Vars API

Phase 13 also added three Rust endpoints for environment variable management. The values are encrypted at rest using AES-256-GCM via the same MasterKey that protects API keys. The ?reveal=true query parameter triggers decryption -- by default, values are masked with asterisks. This is a small detail, but it matters: accidentally displaying a database password in a screen share should require an explicit action.

The i18n Expansion

Every new section of UI meant new translation keys. By the end of Phase 13, the English locale file had grown by 9 sections: dashboard, apps, deployments, domains, logs, env, status, settings, and tabs. All five language files were updated in lockstep.

Phase 14: Extended Pages

The final push replaced the remaining four placeholder pages with production implementations.

Databases page. Card grid with engine icons (PostgreSQL, MySQL, MongoDB, Redis, MariaDB), a create modal with engine selection and default version hints, search, pagination, and delete. The backend enforced an engine whitelist -- you cannot create a "cockroachdb" instance because we do not yet manage that engine's lifecycle.

Backups page. Two-tab layout: History and Schedules. The history tab shows past backups with status badges (completed, failed, in-progress), trigger and restore buttons, and delete. The schedules tab lets you create cron-based backup schedules, toggle them on/off, and delete them. The cron expression input validates on the client before submission.

Monitoring page. Two tabs again: Metrics and Alerts. The metrics tab shows CPU and memory gauges that auto-refresh every 15 seconds. The alerts tab provides CRUD for alert rules -- threshold-based triggers on CPU, memory, or disk usage.

Settings page. Server information, profile editing, TOTP two-factor authentication setup (with QR code and backup codes), and API key management. The TotpSetup.svelte component handles the full enable/disable flow: generate secret, display QR code, verify a code, show backup codes, confirm. The ApiKeyManager.svelte component lists active keys, creates new ones (showing the secret exactly once), and deletes old ones.

Execution Strategy

Phase 14 used a wave strategy. Wave 1 ran three parallel agents: backend handlers, frontend types/API/i18n, and the settings sub-components. Wave 2 wrote the four page files sequentially, since each one imported the types and API modules from Wave 1. Total time: one session. Zero blockers.

Embedding the Dashboard in a Rust Binary

This is the part that makes sh0 feel magical to deploy. The entire dashboard -- every HTML file, every JavaScript bundle, every CSS file -- is compiled into the Rust binary at build time using the include_dir crate.

// crates/sh0/build.rs
fn main() {
    println!("cargo:rerun-if-changed=../dashboard/build");
}
// crates/sh0-api/src/handlers/static_files.rs
use include_dir::{include_dir, Dir};

static DASHBOARD: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../dashboard/build");

pub async fn serve_dashboard(uri: axum::http::Uri) -> impl IntoResponse { let path = uri.path().trim_start_matches('/'); match DASHBOARD.get_file(path) { Some(file) => { let mime = mime_guess::from_path(path).first_or_octet_stream(); ([(header::CONTENT_TYPE, mime.as_ref())], file.contents()).into_response() } None => { // SPA fallback: serve index.html for all unknown routes let index = DASHBOARD.get_file("index.html").unwrap(); ([(header::CONTENT_TYPE, "text/html")], index.contents()).into_response() } } } ```

The build.rs script tells Cargo to rebuild when the dashboard build output changes. The static_files.rs handler serves files from the embedded directory, with SPA fallback to index.html for any path that does not match a real file. This means the SvelteKit client-side router works perfectly -- navigate to /apps/abc123 and the server serves index.html, which bootstraps the SPA and renders the correct page.

The result: sh0 is a single binary. Download it, run it, open a browser. The dashboard is already there. No npm install. No separate frontend server. No reverse proxy configuration.

What We Learned

Svelte 5 runes are a genuine leap. We wrote 35 source files (routes, stores, components, i18n, types) in Phase 12 alone, and never once fought the framework. The $state / $derived / $effect model maps directly to how you think about UI: "this value changes, this other value depends on it, and this side effect should run when either changes."

TailwindCSS 4 with CSS custom properties is the right theming approach. Dark mode is not a feature you bolt on -- it is a constraint that shapes your entire design system. By using variables from the start, every component we wrote worked in both themes automatically.

i18n from day one costs almost nothing. We wrote translation keys alongside components. The marginal cost per component was maybe 30 seconds. Retrofitting i18n into an existing dashboard would have taken days.

Embedding a SPA into a Rust binary is surprisingly smooth. The include_dir crate, a build.rs trigger, and an SPA fallback handler -- three small pieces that eliminate an entire deployment concern.

In 48 hours, we went from an empty directory to a dashboard with 11 functional pages, 19 custom components, 5 languages, dark/light themes, real-time log streaming, and encrypted environment variables -- all compiled into a single Rust binary. Not bad for a team of two.

---

Next in the series: From Flat Lists to Stacks: Redesigning Our Entire UX -- how we threw away the flat app list and rebuilt around project-scoped stacks with a dual sidebar and cPanel-style sections.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles