Build a Svelte Inventory Dashboard

Build a SvelteKit inventory dashboard (auth, charts, CRUD) on SmallStack's CRUDView REST API

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_URL in 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=drill runs a multi-field, case-insensitive substring match across all search fields at once (name, SKU, description). ?q=hex finds 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, a resource() CRUD helper, and ApiError field-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>

Login


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.

Dashboard


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}

Items list

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}

Item form

Editing an expanded FK: because api_expand_fields returns category: {"id", "name"}, ItemForm extracts the id with fkId(...) 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 &amp; 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).

About page


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. ```