Build a Svelte Inventory Dashboard with SmallStack's API¶
π‘ Prefer the big picture first? Read the one-page Svelte Γ SmallStack story β what you get for free when SmallStack is your backend β then come back and build it.
This tutorial walks you through building a SvelteKit dashboard that consumes SmallStack's CRUDView REST API. By the end you'll have a working inventory manager with a real login flow, a stats dashboard with reactive charts, and full CRUD β all powered by SmallStack's enable_api = True.
The focus is the glue: getting to a proper "hello world" against a SmallStack backend with the right tooling, authentication, and security β and letting Svelte's runes do what they do best.
What you'll build:
- A Django backend with an inventory app (Supplier, Warehouse, Bin, Category, Item) whose CRUDViews expose a REST API from one line:
enable_api = True - A real token login flow β no hardcoded token β with a guarded layout and a
localStorage-backed session - A SvelteKit dashboard: aggregation-driven stat cards and bar charts, an items list with search / filter / pagination, forms with validation, and a suppliers section
- A "Svelte + SmallStack" badge linking to an in-app About page
Why Svelte for dashboards:
Svelte's $state and $derived runes make it natural to compute dashboard stats from API data β change the data and every stat card, chart, and table updates automatically. No useEffect dependency arrays, no virtual DOM diffing, and a smaller compiled bundle.
Prerequisites:
- Python 3.11+ with uv installed
- Node.js 18+
- Git
Ports used:
| Service | Port |
|---|---|
| Django backend | 8050 |
| SvelteKit frontend | 8031 |
We use 8050 for the backend (the old default 8005 is commonly already in use locally). Any free port works β keep the frontend's
VITE_API_BASE_URLin sync.
Step 1: Clone and set up the backend¶
mkdir inventory-dashboard
cd inventory-dashboard
git clone https://github.com/emichaud/django-smallstack.git backend
cd backend
cp .env.example .env
CORS: in development, leave CORS_ALLOWED_ORIGINS empty in .env. SmallStack's dev settings auto-allow any http://localhost:<port> origin, so the Vite dev server can call the API with no extra config:
# .env β leave empty in dev; dev settings allow any localhost:* origin.
CORS_ALLOWED_ORIGINS=
If you set explicit origins instead, include your frontend's exact origin (
http://localhost:8031) or the browser silently blocks every request and the app hangs on "Loadingβ¦".
Run setup (installs deps, migrates, creates the admin/admin superuser), and add a non-admin demo user for a safe public preview:
make setup
uv run python manage.py create_demo_user # demo / demo (not staff, not superuser)
make run PORT=8050
Verify. Open http://localhost:8050/health/ for {"status": "ok", ...}, and browse the auto-generated API docs at http://localhost:8050/api/docs/ (Swagger).
Step 2: Create your models¶
Create the inventory app:
uv run python manage.py startapp inventory apps/inventory
Set the app name in apps/inventory/apps.py:
from django.apps import AppConfig
class InventoryConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.inventory"
verbose_name = "Inventory"
Replace apps/inventory/models.py. Items live in a bin inside a warehouse, belong to a category, and optionally come from a supplier:
from decimal import Decimal
from django.db import models
class Supplier(models.Model):
name = models.CharField(max_length=200)
contact_email = models.EmailField(blank=True, default="")
lead_time_days = models.PositiveIntegerField(default=7)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Warehouse(models.Model):
name = models.CharField(max_length=200)
address = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Bin(models.Model):
name = models.CharField(max_length=100)
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, related_name="bins")
location_description = models.TextField(blank=True, default="")
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Category(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name_plural = "categories"
ordering = ["name"]
def __str__(self):
return self.name
class Item(models.Model):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISCONTINUED = "discontinued", "Discontinued"
BACKORDERED = "backordered", "Backordered"
name = models.CharField(max_length=200)
sku = models.CharField(max_length=50, unique=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="items")
bin = models.ForeignKey(Bin, on_delete=models.CASCADE, related_name="items")
supplier = models.ForeignKey(
Supplier, on_delete=models.SET_NULL, null=True, blank=True, related_name="items"
)
quantity = models.PositiveIntegerField(default=0)
reorder_threshold = models.PositiveIntegerField(default=0)
unit_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"))
status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE)
is_active = models.BooleanField(default=True)
description = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.sku} Β· {self.name}"
@property
def is_low_stock(self) -> bool:
return self.quantity <= self.reorder_threshold
@property
def stock_value(self) -> Decimal:
return self.unit_price * self.quantity
is_low_stock and stock_value are Python properties, not columns β we'll expose them read-only through the API so the dashboard can just read them.
Register the app in config/settings/base.py:
INSTALLED_APPS = [
# ...existing custom apps...
"apps.website",
"apps.inventory", # Add this line
# Django built-in apps...
]
Migrate:
uv run python manage.py makemigrations inventory
uv run python manage.py migrate
Step 3: Create CRUDViews with the API enabled¶
Replace apps/inventory/views.py. One line β enable_api = True β turns each model into a REST resource:
from django.contrib.auth.mixins import LoginRequiredMixin
from apps.smallstack.crud import Action, CRUDView
from apps.smallstack.displays import TableDisplay
from .models import Bin, Category, Item, Supplier, Warehouse
_ACTIONS = [Action.LIST, Action.CREATE, Action.DETAIL, Action.UPDATE, Action.DELETE]
class SupplierCRUDView(CRUDView):
model = Supplier
fields = ["name", "contact_email", "lead_time_days", "is_active"]
url_base = "inventory/suppliers"
paginate_by = 25
mixins = [LoginRequiredMixin]
displays = [TableDisplay]
actions = _ACTIONS
enable_api = True
search_fields = ["name", "contact_email"]
filter_fields = ["is_active"]
api_extra_fields = ["created_at"]
class WarehouseCRUDView(CRUDView):
model = Warehouse
fields = ["name", "address"]
url_base = "inventory/warehouses"
mixins = [LoginRequiredMixin]
displays = [TableDisplay]
actions = _ACTIONS
enable_api = True
search_fields = ["name", "address"]
class BinCRUDView(CRUDView):
model = Bin
fields = ["name", "warehouse", "location_description"]
url_base = "inventory/bins"
mixins = [LoginRequiredMixin]
displays = [TableDisplay]
actions = _ACTIONS
enable_api = True
search_fields = ["name"]
filter_fields = ["warehouse"]
api_expand_fields = ["warehouse"]
class CategoryCRUDView(CRUDView):
model = Category
fields = ["name", "description"]
url_base = "inventory/categories"
mixins = [LoginRequiredMixin]
displays = [TableDisplay]
actions = _ACTIONS
enable_api = True
search_fields = ["name"]
class ItemCRUDView(CRUDView):
model = Item
fields = [
"name", "sku", "category", "bin", "supplier", "quantity",
"reorder_threshold", "unit_price", "status", "is_active", "description",
]
url_base = "inventory/items"
paginate_by = 25
mixins = [LoginRequiredMixin]
displays = [TableDisplay]
actions = _ACTIONS
enable_api = True
search_fields = ["name", "sku", "description"]
filter_fields = ["category", "bin", "supplier", "status", "is_active"]
# Model properties, exposed read-only alongside real fields:
api_extra_fields = ["is_low_stock", "stock_value", "created_at", "updated_at"]
api_expand_fields = ["category", "bin", "supplier"]
# Only real DB columns are aggregatable (stock_value is a property):
api_aggregate_fields = ["quantity", "unit_price"]
We use LoginRequiredMixin so any authenticated user (including the non-admin demo user) can use the API.
What that gives you on every list endpoint, for free:
- Search (
search_fields) β?q=drillruns a multi-field, case-insensitive substring match across all search fields at once (name, SKU, description).?q=hexfinds by name,?q=TL-finds by SKU. - Filter (
filter_fields) β?status=active&category=2, exact matches on a field. Search finds; filters narrow. - Ordering β
?ordering=-unit_price - FK expansion β
?expand=category,supplierβ{"id", "name"}instead of a bare id - Pagination β
?page=2&page_size=25 - Aggregation β
?sum=quantity,?avg=unit_price,?count_by=status
Wire the URLs β create apps/inventory/urls.py:
from .views import (
BinCRUDView, CategoryCRUDView, ItemCRUDView, SupplierCRUDView, WarehouseCRUDView,
)
app_name = "inventory"
urlpatterns = [
*SupplierCRUDView.get_urls(),
*WarehouseCRUDView.get_urls(),
*BinCRUDView.get_urls(),
*CategoryCRUDView.get_urls(),
*ItemCRUDView.get_urls(),
]
Include them in config/urls.py (before the website include):
urlpatterns = [
path("", include("apps.inventory.urls")),
path("", include("apps.website.urls")),
# ...
]
Step 4: Seed data and verify the API¶
The dashboard needs some data. The simplest path is a small seed command β create apps/inventory/management/commands/seed_inventory.py (add empty __init__.py files in management/ and management/commands/):
from decimal import Decimal
from django.core.management.base import BaseCommand
from apps.inventory.models import Bin, Category, Item, Supplier, Warehouse
class Command(BaseCommand):
help = "Seed a small inventory dataset."
def handle(self, *args, **options):
wh, _ = Warehouse.objects.get_or_create(name="Seattle DC")
bin_a, _ = Bin.objects.get_or_create(name="A-01", warehouse=wh)
supplier, _ = Supplier.objects.get_or_create(name="Acme Components")
rows = [
# name, sku, category, qty, reorder, price, status
("Cordless Drill 18V", "TL-DRILL18", "Tools", 9, 10, "129.00", "active"),
("Impact Driver 18V", "TL-IMPACT18", "Tools", 0, 8, "149.00", "backordered"),
("Safety Glasses", "SAF-GLASS", "Safety", 400, 75, "3.20", "active"),
("Hi-Vis Vest (L)", "SAF-VEST-L", "Safety", 12, 20, "9.99", "active"),
("20A Circuit Breaker", "ELE-BRK20", "Electrical", 6, 15, "22.40", "backordered"),
("M8 Hex Nut (100pk)", "FST-M8N-100", "Fasteners", 30, 40, "5.25", "active"),
]
for name, sku, cat_name, qty, reorder, price, status in rows:
cat, _ = Category.objects.get_or_create(name=cat_name)
Item.objects.get_or_create(sku=sku, defaults={
"name": name, "category": cat, "bin": bin_a, "supplier": supplier,
"quantity": qty, "reorder_threshold": reorder,
"unit_price": Decimal(price), "status": status,
})
self.stdout.write(self.style.SUCCESS("Seeded inventory."))
uv run python manage.py seed_inventory
Now verify the API. Instead of a hardcoded token, use the login endpoint β the same one the app will use:
# Log in for a token
curl -s -X POST http://localhost:8050/api/auth/token/ \
-H "Content-Type: application/json" -d '{"username": "admin", "password": "admin"}'
# β {"token": "β¦"}
TOKEN=<paste>
# List items with expansion + the computed fields
curl -s "http://localhost:8050/api/inventory/items/?expand=category&sum=quantity&count_by=status" \
-H "Authorization: Bearer $TOKEN"
The response carries the standard envelope, expanded FKs, the read-only is_low_stock/stock_value, and the aggregation extras:
{
"count": 6, "page": 1, "total_pages": 1, "next": null, "previous": null,
"sum_quantity": 457,
"counts": {"active": 4, "backordered": 2},
"results": [ { "sku": "TL-DRILL18", "category": {"id": 1, "name": "Tools"},
"quantity": 9, "is_low_stock": true, "stock_value": "1161.00", "β¦": "β¦" } ]
}
Step 5: Scaffold the SvelteKit frontend¶
Open a new terminal. From inventory-dashboard:
npx sv create frontend --template minimal --types ts --no-add-ons
cd frontend
npm install
Set the dev port in vite.config.ts:
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: { port: 8031 }
});
Point the app at the backend. Create frontend/.env:
VITE_API_BASE_URL=http://localhost:8050/api
No token here β the app logs in and stores its token in localStorage.
Step 6: Build the typed API client¶
Shortcut available. SmallStack ships a ready-made client (
clients/js) that gives you exactly this β auth, aresource()CRUD helper, andApiErrorfield-error parsing β in a couple of lines (npm install ../backend/clients/js). The React tutorial uses it. Here we build the equivalent by hand so you can see precisely what it does; it's about 40 lines and no magic.
Create src/lib/api.ts. One generic resource() factory covers every CRUDView, and the token is managed here (kept in sync by the auth store in the next step):
const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8050/api';
let authToken: string | null = null;
export function setToken(token: string | null) { authToken = token; }
export class ApiError extends Error {
status: number;
data: any;
constructor(status: number, data: any) {
super(`API Error ${status}`);
this.status = status;
this.data = data;
}
}
export async function request(path: string, options: Omit<RequestInit, 'body'> & { body?: any } = {}) {
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
if (authToken) headers.Authorization = `Bearer ${authToken}`;
if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(options.body);
}
const response = await fetch(`${BASE_URL}${path}`, { ...options, headers });
if (response.status === 204) return null;
const data = await response.json().catch(() => null);
if (!response.ok) throw new ApiError(response.status, data);
return data;
}
export interface ExpandedFK { id: number; name: string; }
export interface PaginatedResponse<T> {
count: number; page: number; total_pages: number;
next: string | null; previous: string | null; results: T[];
counts?: Record<string, number>;
sum_quantity?: number | null;
avg_unit_price?: number | null;
}
export type ItemStatus = 'active' | 'discontinued' | 'backordered';
export interface Item {
id: number; name: string; sku: string;
category: number | ExpandedFK; bin: number | ExpandedFK; supplier: number | ExpandedFK | null;
quantity: number; reorder_threshold: number; unit_price: string;
status: ItemStatus; is_active: boolean; description: string;
is_low_stock?: boolean; stock_value?: string;
}
export interface Supplier {
id: number; name: string; contact_email: string; lead_time_days: number; is_active: boolean;
}
export interface Category { id: number; name: string; description: string; }
export interface Bin { id: number; name: string; warehouse: number | ExpandedFK; }
export type Params = Record<string, string | number | boolean | undefined>;
function buildParams(params: Params): string {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== '') p.set(k, String(v));
const s = p.toString();
return s ? `?${s}` : '';
}
// Every CRUDView exposes the same shape.
function resource<T>(base: string) {
return {
list: (params: Params = {}): Promise<PaginatedResponse<T>> => request(`${base}/${buildParams(params)}`),
get: (id: number): Promise<T> => request(`${base}/${id}/`),
create: (data: Partial<T>): Promise<T> => request(`${base}/`, { method: 'POST', body: data }),
update: (id: number, data: Partial<T>): Promise<T> => request(`${base}/${id}/`, { method: 'PATCH', body: data }),
remove: (id: number): Promise<null> => request(`${base}/${id}/`, { method: 'DELETE' })
};
}
export const api = {
items: resource<Item>('/inventory/items'),
suppliers: resource<Supplier>('/inventory/suppliers'),
categories: resource<Category>('/inventory/categories'),
bins: resource<Bin>('/inventory/bins'),
login: (username: string, password: string): Promise<{ token: string }> =>
request('/auth/token/', { method: 'POST', body: { username, password } }),
me: (): Promise<{ id: number; username: string }> => request('/auth/me/'),
logout: (): Promise<any> => request('/auth/logout/', { method: 'POST' }).catch(() => null),
};
export const fkId = (v: number | ExpandedFK | null | undefined): number | '' =>
v == null ? '' : typeof v === 'object' ? v.id : v;
export const fkName = (v: number | ExpandedFK | null | undefined, fallback = 'β'): string =>
v == null ? fallback : typeof v === 'object' ? v.name : `#${v}`;
The search param is
?q=, not?search=. Unknown query params are silently ignored, so?search=would return every row β the box would look like it works while filtering nothing.
Step 7: Authentication β done right¶
Rather than a hardcoded token, we log in and hold a real session. Runes work in a .svelte.ts module, so the auth store is tiny.
Create src/lib/auth.svelte.ts:
import { browser } from '$app/environment';
import { api, setToken } from './api';
const STORAGE_KEY = 'smallstack_token';
let token = $state<string | null>(null);
let user = $state<{ id: number; username: string } | null>(null);
let ready = $state(false);
export const auth = {
get token() { return token; },
get user() { return user; },
get isAuthenticated() { return !!token; },
get ready() { return ready; },
};
export async function initAuth() {
if (!browser) { ready = true; return; }
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
token = stored;
setToken(stored);
try { user = await api.me(); } catch { await logout(); }
}
ready = true;
}
export async function login(username: string, password: string) {
const res = await api.login(username, password);
token = res.token;
setToken(res.token);
if (browser) localStorage.setItem(STORAGE_KEY, res.token);
user = await api.me();
}
export async function logout() {
try { await api.logout(); } finally {
token = null; user = null; setToken(null);
if (browser) localStorage.removeItem(STORAGE_KEY);
}
}
Create the login page src/routes/login/+page.svelte:
<script lang="ts">
import { goto } from '$app/navigation';
import { login } from '$lib/auth.svelte';
import ErrorDisplay from '$lib/components/ErrorDisplay.svelte';
let username = $state('admin');
let password = $state('admin');
let error = $state<any>(null);
let busy = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
busy = true; error = null;
try { await login(username, password); goto('/'); }
catch (e) { error = e; busy = false; }
}
</script>
<div class="login-wrap">
<div class="card login-card">
<h1>Sign in</h1>
<ErrorDisplay {error} />
<form onsubmit={handleSubmit}>
<div class="form-group">
<label for="u">Username</label>
<input id="u" type="text" bind:value={username} />
</div>
<div class="form-group">
<label for="p">Password</label>
<input id="p" type="password" bind:value={password} />
</div>
<button type="submit" class="btn btn-primary" disabled={busy} style="width:100%">
{busy ? 'Signing inβ¦' : 'Sign in'}
</button>
</form>
<p class="login-hint">Dev credentials: <code>admin</code> / <code>admin</code> (or <code>demo</code> / <code>demo</code>).</p>
</div>
</div>

Step 8: Reusable components¶
src/lib/components/ErrorDisplay.svelte β maps SmallStack's {"errors": {...}} to per-field messages:
<script lang="ts">
import { ApiError } from '$lib/api';
let { error }: { error: any } = $props();
</script>
{#if error}
<div class="error-box">
{#if error instanceof ApiError && error.data?.errors}
<strong>Validation Errors:</strong>
<ul style="margin:4px 0 0; padding-left:20px;">
{#each Object.entries(error.data.errors) as [field, messages]}
<li><strong>{field}:</strong> {Array.isArray(messages) ? messages.join(', ') : messages}</li>
{/each}
</ul>
{:else}
{error?.data?.error || error?.message || 'An error occurred'}
{/if}
</div>
{/if}
src/lib/components/Pagination.svelte:
<script lang="ts">
let { count, next, previous, page, onPageChange }: {
count: number; next: string | null; previous: string | null;
page: number; onPageChange: (page: number) => void;
} = $props();
</script>
{#if next || previous}
<div class="pagination">
<button disabled={!previous} onclick={() => onPageChange(page - 1)}>Previous</button>
<span>Page {page} ({count} total)</span>
<button disabled={!next} onclick={() => onPageChange(page + 1)}>Next</button>
</div>
{/if}
Step 9: The dashboard¶
This is where Svelte shines. The stat cards and charts are $derived from the API's aggregation β the backend computes the totals. Replace src/routes/+page.svelte:
<script lang="ts">
import { onMount } from 'svelte';
import { api, fkName, type Item } from '$lib/api';
let loading = $state(true);
let error = $state<string | null>(null);
let itemCount = $state(0);
let statusCounts = $state<Record<string, number>>({});
let onHandUnits = $state(0);
let allItems = $state<Item[]>([]);
let lowStock = $derived(allItems.filter((i) => i.is_low_stock));
let inventoryValue = $derived(allItems.reduce((s, i) => s + parseFloat(i.stock_value ?? '0'), 0));
let statusData = $derived([
{ label: 'Active', count: statusCounts['active'] || 0, color: 'var(--success)' },
{ label: 'Backordered', count: statusCounts['backordered'] || 0, color: 'var(--warning)' },
{ label: 'Discontinued', count: statusCounts['discontinued'] || 0, color: 'var(--text-muted)' }
]);
let valueByCategory = $derived.by(() => {
const map = new Map<string, number>();
for (const i of allItems) {
const name = fkName(i.category, 'Uncategorized');
map.set(name, (map.get(name) ?? 0) + parseFloat(i.stock_value ?? '0'));
}
return [...map.entries()].map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value);
});
const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
onMount(async () => {
try {
// Server-side aggregation: fetch numbers, not whole tables.
const [byStatus, qtyAgg, itemsRes] = await Promise.all([
api.items.list({ count_by: 'status' }),
api.items.list({ sum: 'quantity' }),
api.items.list({ page_size: 200, expand: 'category' }),
]);
itemCount = byStatus.count;
statusCounts = byStatus.counts || {};
onHandUnits = qtyAgg.sum_quantity ?? 0;
allItems = itemsRes.results;
} catch (e: any) {
error = e?.data?.error || e?.message || 'Failed to load dashboard';
} finally { loading = false; }
});
</script>
<div class="page-header"><h1>Inventory Dashboard</h1></div>
{#if loading}
<div class="loading">Loading dashboardβ¦</div>
{:else if error}
<div class="error-box">{error}</div>
{:else}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Items</div>
<div class="stat-value" style="color: var(--primary)">{itemCount}</div>
<div class="stat-sub">{statusCounts['active'] || 0} active</div>
</div>
<div class="stat-card">
<div class="stat-label">On-hand Units</div>
<div class="stat-value" style="color: var(--info)">{onHandUnits.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Low Stock</div>
<div class="stat-value" style="color: {lowStock.length ? 'var(--warning)' : 'var(--success)'}">{lowStock.length}</div>
</div>
<div class="stat-card">
<div class="stat-label">Inventory Value</div>
<div class="stat-value" style="color: var(--success)">{money(inventoryValue)}</div>
</div>
</div>
<div class="chart-grid">
<div class="card">
<div class="card-header">Items by Status</div>
<div class="card-body">
<div class="bar-chart">
{#each statusData as item}
{@const max = Math.max(...statusData.map((d) => d.count), 1)}
<div class="bar-row">
<div class="bar-label">{item.label}</div>
<div class="bar-track">
<div class="bar-fill" style="width: {(item.count / max) * 100}%; background: {item.color}">
{#if item.count > 0}{item.count}{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
<div class="card">
<div class="card-header">Stock Value by Category</div>
<div class="card-body">
<div class="bar-chart">
{#each valueByCategory as item}
{@const max = Math.max(...valueByCategory.map((d) => d.value), 1)}
<div class="bar-row">
<div class="bar-label">{item.name}</div>
<div class="bar-track">
<div class="bar-fill" style="width: {(item.value / max) * 100}%; background: var(--primary)">
{money(item.value)}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Low-stock Items <a href="/items?low=1" class="btn btn-sm">View in list</a></div>
<table>
<thead><tr><th>SKU</th><th>Name</th><th>Category</th><th>On hand</th><th>Reorder at</th></tr></thead>
<tbody>
{#each lowStock as item}
<tr class="row-low">
<td><a href="/items/{item.id}/edit">{item.sku}</a></td>
<td>{item.name}</td><td>{fkName(item.category)}</td>
<td>{item.quantity}</td><td>{item.reorder_threshold}</td>
</tr>
{:else}
<tr><td colspan="5" class="empty">Nothing low on stock β nice.</td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
What makes this efficient: ?count_by=status and ?sum=quantity compute totals on the server in one request each β no multi-page fetching. $derived recomputes the low-stock list and category chart automatically from the fetched items. is_low_stock and stock_value arrive ready to use.

Step 10: The items list and form¶
Items list¶
src/routes/items/+page.svelte β search (?q=), a status filter, FK expansion, pagination, low-stock highlighting:
<script lang="ts">
import { onMount } from 'svelte';
import { api, fkName, type Item, type PaginatedResponse } from '$lib/api';
import Pagination from '$lib/components/Pagination.svelte';
let pageData = $state<PaginatedResponse<Item> | null>(null);
let error = $state<string | null>(null);
let search = $state('');
let status = $state('');
let currentPage = $state(1);
async function load() {
try {
error = null;
pageData = await api.items.list({
page: currentPage,
q: search || undefined, // search param is ?q=
status: status || undefined, // exact filter
ordering: 'name',
expand: 'category,bin,supplier',
});
} catch (e: any) { error = e?.data?.error || e?.message || 'Failed to load items'; }
}
function applyFilters() { currentPage = 1; load(); }
async function remove(item: Item) {
if (!confirm(`Delete ${item.sku}?`)) return;
try { await api.items.remove(item.id); load(); } catch (e: any) { error = e?.message; }
}
const money = (v: string) => parseFloat(v).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
onMount(load);
</script>
<div class="page-header">
<h1>Items</h1>
<a href="/items/new" class="btn btn-primary">New Item</a>
</div>
<div class="toolbar">
<div class="search-bar">
<input type="text" placeholder="Search name, SKU, descriptionβ¦"
bind:value={search} onkeydown={(e) => e.key === 'Enter' && applyFilters()} />
</div>
<div class="filters">
<select bind:value={status} onchange={applyFilters}>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="backordered">Backordered</option>
<option value="discontinued">Discontinued</option>
</select>
<button class="btn" onclick={applyFilters}>Search</button>
</div>
</div>
{#if error}<div class="error-box">{error}</div>{/if}
<div class="card">
{#if pageData}
<table>
<thead>
<tr><th>SKU</th><th>Name</th><th>Category</th><th>Bin</th><th>Qty</th><th>Unit price</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{#each pageData.results as item}
<tr class:row-low={item.is_low_stock}>
<td><a href="/items/{item.id}/edit">{item.sku}</a></td>
<td>{item.name}</td>
<td>{fkName(item.category)}</td>
<td>{fkName(item.bin)}</td>
<td>{item.quantity}{item.is_low_stock ? ' β οΈ' : ''}</td>
<td>{money(item.unit_price)}</td>
<td><span class="badge badge-{item.status}">{item.status}</span></td>
<td class="row-actions">
<a href="/items/{item.id}/edit" class="btn btn-sm">Edit</a>
<button class="btn btn-sm btn-danger" onclick={() => remove(item)}>Delete</button>
</td>
</tr>
{:else}
<tr><td colspan="8" class="empty">No items match.</td></tr>
{/each}
</tbody>
</table>
{:else}
<div class="loading">Loading itemsβ¦</div>
{/if}
</div>
{#if pageData}
<Pagination count={pageData.count} next={pageData.next} previous={pageData.previous}
page={currentPage} onPageChange={(p) => { currentPage = p; load(); }} />
{/if}

Item form (create + edit)¶
A shared component handles both. src/lib/components/ItemForm.svelte loads the category / bin / supplier options and maps validation errors to fields. bind:value is Svelte's two-way binding β no onChange handlers:
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api, fkId, type Item, type Category, type Bin, type Supplier } from '$lib/api';
import ErrorDisplay from './ErrorDisplay.svelte';
let { item = null }: { item?: Item | null } = $props();
const isEdit = !!item;
let form = $state({
name: item?.name ?? '', sku: item?.sku ?? '',
category: item ? fkId(item.category) : '', bin: item ? fkId(item.bin) : '',
supplier: item ? fkId(item.supplier) : '',
quantity: item?.quantity ?? 0, reorder_threshold: item?.reorder_threshold ?? 0,
unit_price: item?.unit_price ?? '0.00', status: item?.status ?? 'active',
is_active: item?.is_active ?? true, description: item?.description ?? '',
});
let categories = $state<Category[]>([]);
let bins = $state<Bin[]>([]);
let suppliers = $state<Supplier[]>([]);
let error = $state<any>(null);
let fieldErrors = $state<Record<string, string[]>>({});
let saving = $state(false);
onMount(async () => {
const [c, b, s] = await Promise.all([
api.categories.list({ page_size: 200, ordering: 'name' }),
api.bins.list({ page_size: 200, ordering: 'name' }),
api.suppliers.list({ page_size: 200, ordering: 'name' }),
]);
categories = c.results; bins = b.results; suppliers = s.results;
});
async function handleSubmit(e: Event) {
e.preventDefault();
saving = true; error = null; fieldErrors = {};
const payload: any = {
...form,
category: Number(form.category), bin: Number(form.bin),
supplier: form.supplier === '' ? null : Number(form.supplier),
quantity: Number(form.quantity), reorder_threshold: Number(form.reorder_threshold),
};
try {
if (isEdit && item) await api.items.update(item.id, payload);
else await api.items.create(payload);
goto('/items');
} catch (e: any) {
if (e.data?.errors) fieldErrors = e.data.errors;
error = e; saving = false;
}
}
</script>
<ErrorDisplay {error} />
<div class="card"><div class="card-body">
<form onsubmit={handleSubmit}>
<div class="form-row">
<div class="form-group">
<label for="name">Name</label>
<input id="name" type="text" bind:value={form.name} />
{#if fieldErrors.name}<span class="field-error">{fieldErrors.name.join(', ')}</span>{/if}
</div>
<div class="form-group">
<label for="sku">SKU</label>
<input id="sku" type="text" bind:value={form.sku} />
{#if fieldErrors.sku}<span class="field-error">{fieldErrors.sku.join(', ')}</span>{/if}
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="cat">Category</label>
<select id="cat" bind:value={form.category}>
<option value="" disabled>Selectβ¦</option>
{#each categories as c}<option value={c.id}>{c.name}</option>{/each}
</select>
</div>
<div class="form-group">
<label for="bin">Bin</label>
<select id="bin" bind:value={form.bin}>
<option value="" disabled>Selectβ¦</option>
{#each bins as b}<option value={b.id}>{b.name}</option>{/each}
</select>
</div>
<div class="form-group">
<label for="sup">Supplier</label>
<select id="sup" bind:value={form.supplier}>
<option value="">β none β</option>
{#each suppliers as s}<option value={s.id}>{s.name}</option>{/each}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group"><label for="qty">Quantity</label>
<input id="qty" type="number" min="0" bind:value={form.quantity} /></div>
<div class="form-group"><label for="reorder">Reorder threshold</label>
<input id="reorder" type="number" min="0" bind:value={form.reorder_threshold} /></div>
<div class="form-group"><label for="price">Unit price</label>
<input id="price" type="number" step="0.01" min="0" bind:value={form.unit_price} />
{#if fieldErrors.unit_price}<span class="field-error">{fieldErrors.unit_price.join(', ')}</span>{/if}</div>
<div class="form-group"><label for="status">Status</label>
<select id="status" bind:value={form.status}>
<option value="active">Active</option>
<option value="backordered">Backordered</option>
<option value="discontinued">Discontinued</option>
</select></div>
</div>
<div class="form-group">
<label for="desc">Description</label>
<textarea id="desc" bind:value={form.description}></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Savingβ¦' : isEdit ? 'Save changes' : 'Create item'}
</button>
<a href="/items" class="btn">Cancel</a>
</div>
</form>
</div></div>
Then two thin routes use it β SvelteKit's file-based routing makes create/edit natural:
<!-- src/routes/items/new/+page.svelte -->
<script lang="ts">
import ItemForm from '$lib/components/ItemForm.svelte';
</script>
<div class="page-header"><h1>New Item</h1></div>
<ItemForm />
<!-- src/routes/items/[id]/edit/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { api, type Item } from '$lib/api';
import ItemForm from '$lib/components/ItemForm.svelte';
let item = $state<Item | null>(null);
onMount(async () => { item = await api.items.get(Number(page.params.id)); });
</script>
<div class="page-header"><h1>Edit Item</h1></div>
{#if item}<ItemForm {item} />{:else}<div class="loading">Loadingβ¦</div>{/if}

Editing an expanded FK: because
api_expand_fieldsreturnscategory: {"id", "name"},ItemFormextracts the id withfkId(...)when seeding the form. Options for the dropdowns are fetched separately β expansion solves display, not option lists.
Suppliers follow the exact same pattern β a list page plus a shared SupplierForm.svelte, wired at src/routes/suppliers/. Nothing new; just a simpler model (no FKs). Warehouse/Bin/Category lists are optional catalog pages you can add the same way.
Step 11: The badge, About page, and guarded layout¶
A small "Svelte + SmallStack" badge links to a public one-page explainer. Create src/lib/components/BuiltWith.svelte:
<script lang="ts">
let { framework = 'Svelte' }: { framework?: string } = $props();
</script>
<a class="built-with" href="/about" title="How the frontend and SmallStack fit together">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{framework} <span class="bw-x">+</span> SmallStack</span>
</a>
The About page (src/routes/about/+page.svelte) is public and links to Swagger, ReDoc, and the SmallStack Explorer β derive the backend origin from the API base URL:
<script lang="ts">
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8050/api';
const backend = apiBase.replace(/\/api\/?$/, '');
</script>
<div class="about">
<a class="about-back" href="/">β Back to the dashboard</a>
<h1>Svelte <span style="color:var(--primary-light)">+</span> SmallStack</h1>
<p class="about-lede">A SvelteKit frontend talking to a Django SmallStack backend β SmallStack does
the heavy lifting behind a clean REST API, so the UI code stays small.</p>
<div class="card about-card"><div class="card-body">
<h3>What SmallStack handed us for free</h3>
<ul>
<li>A complete REST API generated from the models (<code>enable_api = True</code>)</li>
<li>Token auth + user management</li>
<li>Auto-generated API docs β Swagger & ReDoc</li>
<li>Search, filtering, FK expansion, pagination, and server-side aggregation</li>
</ul>
</div></div>
<div class="about-links">
<a class="btn btn-primary" href="{backend}/api/docs/" target="_blank" rel="noreferrer">Swagger API docs β</a>
<a class="btn" href="{backend}/api/redoc/" target="_blank" rel="noreferrer">ReDoc (OpenAPI) β</a>
<a class="btn" href="{backend}/smallstack/explorer/" target="_blank" rel="noreferrer">SmallStack Explorer β</a>
</div>
</div>
Now the layout ties it together: it restores the session on mount, guards every route except /login and /about, and renders the sidebar + badge. Replace src/routes/+layout.svelte:
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { auth, initAuth, logout } from '$lib/auth.svelte';
import BuiltWith from '$lib/components/BuiltWith.svelte';
let { children } = $props();
const nav = [{ href: '/', label: 'Dashboard' }, { href: '/items', label: 'Items' }, { href: '/suppliers', label: 'Suppliers' }];
let isLogin = $derived(page.url.pathname === '/login');
let isAbout = $derived(page.url.pathname === '/about');
onMount(initAuth);
$effect(() => {
if (auth.ready && !auth.isAuthenticated && !isLogin && !isAbout) goto('/login');
});
async function handleLogout() { await logout(); goto('/login'); }
</script>
{#if isLogin}
{@render children()}
{:else if !auth.ready}
<div class="loading">Loadingβ¦</div>
{:else if auth.isAuthenticated}
<div class="app-layout">
<BuiltWith framework="Svelte" />
<aside class="sidebar">
<div class="sidebar-brand">Inventory</div>
<nav>
{#each nav as item}
<a href={item.href} class:active={page.url.pathname === item.href ||
(item.href !== '/' && page.url.pathname.startsWith(item.href))}>{item.label}</a>
{/each}
</nav>
<div class="sidebar-user">
<span>{auth.user?.username ?? 'user'}</span>
<button class="btn btn-sm" onclick={handleLogout}>Sign out</button>
</div>
</aside>
<main class="main-content">{@render children()}</main>
</div>
{:else if isAbout}
{@render children()}
{:else}
<div class="loading">Redirectingβ¦</div>
{/if}
The badge is position: fixed in the top-right corner (see the stylesheet below).

Step 12: Styles and run it¶
Create src/app.css β a compact dark theme. (Abbreviated here; the key custom classes are the stat cards, cards, bar chart, badges, and the fixed badge.)
:root {
--bg: #0f1117; --bg-card: #1a1d27; --bg-sidebar: #141720; --border: #2a2d3a;
--text: #e1e4eb; --text-muted: #8b8fa3; --primary: #6366f1; --primary-light: #818cf8;
--success: #22c55e; --warning: #f59e0b; --danger: #ef4444; --info: #06b6d4; --radius: 8px;
}
* { box-sizing: border-box; margin: 0; }
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); font-size: 14px; }
.app-layout { display: flex; min-height: 100vh; }
.sidebar { width: 220px; background: var(--bg-sidebar); border-right: 1px solid var(--border);
padding: 20px 0; position: sticky; top: 0; align-self: stretch; display: flex; flex-direction: column; }
.sidebar-brand { padding: 0 20px 16px; font-weight: 700; border-bottom: 1px solid var(--border); }
.sidebar nav { display: flex; flex-direction: column; padding: 12px 8px; gap: 2px; }
.sidebar nav a { padding: 8px 12px; border-radius: 6px; color: var(--text-muted); text-decoration: none; font-size: 13px; }
.sidebar nav a.active { background: color-mix(in srgb, var(--primary) 15%, transparent); color: var(--primary-light); }
.sidebar-user { margin-top: auto; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
.main-content { flex: 1; padding: 24px 32px; min-width: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 16px; }
.card-header { padding: 14px 18px; border-bottom: 1px solid var(--border); font-weight: 600;
display: flex; justify-content: space-between; align-items: center; }
.card-body { padding: 18px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; }
.stat-label { font-size: 12px; text-transform: uppercase; color: var(--text-muted); }
.stat-value { font-size: 28px; font-weight: 700; margin-top: 6px; }
.stat-sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.chart-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.bar-chart { display: flex; flex-direction: column; gap: 10px; }
.bar-row { display: grid; grid-template-columns: 110px 1fr; align-items: center; gap: 10px; }
.bar-label { font-size: 13px; color: var(--text-muted); }
.bar-track { background: var(--bg); border-radius: 4px; overflow: hidden; height: 26px; }
.bar-fill { height: 100%; display: flex; align-items: center; padding: 0 8px; color: #fff; font-size: 12px; font-weight: 600; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
th { color: var(--text-muted); text-transform: uppercase; font-size: 12px; }
.row-low { background: color-mix(in srgb, var(--warning) 8%, transparent); }
.badge { padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: capitalize; }
.badge-active { background: color-mix(in srgb, var(--success) 20%, transparent); color: var(--success); }
.badge-backordered { background: color-mix(in srgb, var(--warning) 20%, transparent); color: var(--warning); }
.badge-discontinued { background: color-mix(in srgb, var(--text-muted) 15%, transparent); color: var(--text-muted); }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 6px; font-size: 13px;
cursor: pointer; border: 1px solid var(--border); background: var(--bg-card); color: var(--text); text-decoration: none; }
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn-danger { color: var(--danger); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.toolbar .search-bar { flex: 1; min-width: 220px; }
.search-bar input, .filters select, .form-group input, .form-group textarea, .form-group select {
width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); }
.filters { display: flex; gap: 8px; }
.form-row { display: flex; gap: 16px; flex-wrap: wrap; }
.form-row .form-group { flex: 1; min-width: 160px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
.form-actions { display: flex; gap: 8px; margin-top: 16px; }
.field-error { color: var(--danger); font-size: 12px; }
.row-actions { display: flex; gap: 6px; }
.pagination { display: flex; gap: 12px; align-items: center; justify-content: center; padding: 12px; }
.error-box { background: color-mix(in srgb, var(--danger) 10%, var(--bg-card)); border: 1px solid color-mix(in srgb, var(--danger) 30%, var(--border)); color: var(--danger); padding: 12px 16px; border-radius: var(--radius); margin-bottom: 16px; }
.loading, .empty { padding: 24px; text-align: center; color: var(--text-muted); }
.built-with { position: fixed; top: 14px; right: 18px; z-index: 50; display: inline-flex; align-items: center; gap: 6px;
padding: 6px 11px; border-radius: 999px; border: 1px solid var(--border); background: color-mix(in srgb, var(--primary) 14%, var(--bg-card));
color: var(--text-muted); font-size: 11px; font-weight: 600; text-decoration: none; }
.built-with .bw-x, .built-with svg { color: var(--primary-light); }
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.login-card { width: 100%; max-width: 380px; padding: 28px; }
.login-hint { margin-top: 14px; font-size: 12px; color: var(--text-muted); text-align: center; }
.about { max-width: 720px; } .about-lede { color: var(--text-muted); margin: 12px 0 20px; line-height: 1.6; }
.about-links { display: flex; gap: 10px; flex-wrap: wrap; } .about-back { color: var(--text-muted); text-decoration: none; }
code { background: var(--bg); color: var(--primary-light); padding: 1px 5px; border-radius: 4px; }
Run it β Terminal 1 (backend):
cd inventory-dashboard/backend
make run PORT=8050
Terminal 2 (frontend):
cd inventory-dashboard/frontend
npm run dev
Open http://localhost:8031, sign in with admin / admin (or demo / demo), and the dashboard loads with live, aggregated data.
API Reference¶
Everything below comes from enable_api = True.
| Method | URL | Response |
|---|---|---|
| GET | /api/{url_base}/ |
{"count", "page", "total_pages", "next", "previous", "results": [...]} |
| POST | /api/{url_base}/ |
201 with created object |
| GET/PATCH/DELETE | /api/{url_base}/<id>/ |
object / updated object / 204 |
Auth: POST /api/auth/token/ β {token}; GET /api/auth/me/; POST /api/auth/logout/. Send Authorization: Bearer <token>.
Query params (list):
| Param | Example | Requires |
|---|---|---|
?q=term |
?q=drill |
search_fields (multi-field substring) |
?field=value |
?status=active&category=2 |
filter_fields (exact) |
?ordering=field |
?ordering=-unit_price |
β |
?expand=f1,f2 |
?expand=category,supplier |
api_expand_fields |
?count_by=field |
?count_by=status |
field in filter_fields |
?sum=field ?avg=field |
?sum=quantity |
field in api_aggregate_fields |
Tips and gotchas¶
Search (?q=) vs. filtering¶
?q= is SmallStack's multi-field search: a case-insensitive substring match across every search_fields entry, OR-ed together β one box finds by name, SKU, or description. Filters (?status=, ?category=) are exact single-field matches. Search finds; filters narrow. And note: unknown params are silently ignored, so ?search= (wrong) returns everything, and ?status=act (invalid value) also returns everything β when a filter does nothing, check the param name and value.
Server-side aggregation replaces all-page fetching¶
?count_by=status and ?sum=quantity compute real totals across all rows in one request. count_by fields must be in filter_fields; sum/avg fields must be in api_aggregate_fields (real columns only β not properties like stock_value).
Model properties as read-only fields¶
is_low_stock and stock_value are Python properties. Listing them in api_extra_fields exposes them read-only in responses β perfect for computed values the frontend shouldn't recalculate (you can't filter/aggregate on them server-side, though).
Auth done right¶
No hardcoded token: the app logs in, stores the bearer token in localStorage, and restores the session on load via /api/auth/me/. The layout's $effect guards every route except /login and /about. For a public preview, sign in as the non-staff demo user so the Django admin stays protected.
Svelte 5 runes¶
$state, $derived, $derived.by, $props, and $effect are Svelte 5. {@render children()} replaces <slot />. Runes work in .svelte.ts modules too β that's how the auth store stays a plain import.
CORS in development¶
Leave CORS_ALLOWED_ORIGINS empty in .env; dev settings auto-allow any localhost:* origin. Symptom of a CORS block: the app hangs on "Loadingβ¦" while curl works.
File structure¶
inventory-dashboard/
βββ backend/ # SmallStack clone
β βββ apps/inventory/
β β βββ models.py # Supplier, Warehouse, Bin, Category, Item
β β βββ views.py # CRUDViews with enable_api=True
β β βββ urls.py
β β βββ management/commands/seed_inventory.py
β βββ config/{settings/base.py, urls.py}
βββ frontend/ # SvelteKit + Vite
βββ .env # VITE_API_BASE_URL (no token)
βββ src/
βββ lib/
β βββ api.ts # typed client + resource factory
β βββ auth.svelte.ts # runes auth store (login/logout/me)
β βββ components/ # ErrorDisplay, Pagination, ItemForm, SupplierForm, BuiltWith
βββ routes/
β βββ +layout.svelte # auth guard + sidebar + badge
β βββ +page.svelte # dashboard (aggregation + charts)
β βββ login/+page.svelte
β βββ about/+page.svelte # public "look what you get" page
β βββ items/{+page, new, [id]/edit}
β βββ suppliers/{+page, new, [id]/edit}
βββ app.css # dark theme
That's a complete, authenticated inventory dashboard against a SmallStack backend β reactive charts and all β with almost all of the "backend" being one line per model. The same inventory app is also built with React in this series. ```