Build a Streamlit Data App

Consume SmallStack's API from Python β€” a server-side Streamlit dashboard with server-computed metrics and charts

Build a Streamlit Data App on SmallStack's API

πŸ’‘ Prefer the big picture first? Read the one-page Streamlit Γ— SmallStack story β€” what you get for free when SmallStack is your backend β€” then come back and build it.

Most SmallStack frontend tutorials build a browser SPA. This one is deliberately different: a Streamlit data app written in Python, running server-side. It's the "internal tool / BI dashboard" story β€” and it shows that SmallStack's auto-generated API serves far more than JavaScript.

Why Streamlit is the odd one out (on purpose):

  • No browser token, no localStorage. The app runs Python on a server; the API token lives in that process, never in a user's browser.
  • No CORS. Calls are server-to-server (Streamlit β†’ Django), so none of the cross-origin setup a SPA needs applies.
  • The backend does the math. Metric tiles and charts come straight from SmallStack's aggregation query params (?sum=, ?avg=, ?count_by=) β€” you don't fetch rows to sum them.

What you'll build: a login flow, a dashboard of server-computed metrics + bar charts, an items table with server-side search, filtering, and pagination, and a public "About" page β€” in ~300 lines of Python.

Prerequisites: Python 3.10+ with uv, and a SmallStack backend.

Ports:

Service Port
Django backend 8050
Streamlit app 8501

Step 1: Backend β€” the same inventory API

This track consumes the same inventory backend the other tutorials build. If you haven't already, stand it up (the React tutorial walks through it in full):

git clone https://github.com/emichaud/django-smallstack.git backend
cd backend
cp .env.example .env          # leave CORS_ALLOWED_ORIGINS empty β€” we don't need it here
make setup                    # migrate + admin/admin superuser
uv run python manage.py create_demo_user   # demo/demo, non-admin β€” good for a hosted preview

Add an Item model with a CRUDView (enable_api = True), giving it search_fields, filter_fields = ["status", ...], and api_aggregate_fields = ["quantity", "unit_price"] so the dashboard's ?sum=/?avg=/?count_by= calls work. Seed a few items, then:

make run PORT=8050

Confirm the API is live at http://localhost:8050/api/docs/.

Streamlit doesn't need CORS β€” but if you later serve it somewhere and also hit the API from a browser, that's when CORS matters. For this server-side app, leave it empty.


Step 2: The SmallStack Python client

SmallStack ships a Python client alongside the JS one β€” a single requests-based file. Copy it out of your backend clone into a new app folder:

mkdir ../streamlit-app && cd ../streamlit-app
cp ../backend/clients/python/smallstack_client.py .

It mirrors the JS client: client.auth.login/me/logout, a generic client.api(), and a resource() CRUD helper that raises ApiError (with .field_errors) on failure.

Create pyproject.toml:

[project]
name = "smallstack-streamlit"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["streamlit>=1.36", "requests>=2.31", "python-dotenv>=1.0", "pandas>=2.0"]

And a dev-only .env (the token is derived from these server-side; never ship real creds this way):

SMALLSTACK_API_URL=http://localhost:8050
SMALLSTACK_USERNAME=admin
SMALLSTACK_PASSWORD=admin

Step 3: Client + login (held server-side)

Create app.py. The client is cached with @st.cache_resource so a single instance β€” and its token β€” survives across Streamlit reruns:

from __future__ import annotations

import os
import pandas as pd
import streamlit as st
from dotenv import load_dotenv

from smallstack_client import ApiError, SmallStackClient

load_dotenv()  # read SMALLSTACK_* from .env (the client itself is env-agnostic)

st.set_page_config(page_title="SmallStack Β· Streamlit Data App", page_icon="πŸ“¦", layout="wide")
STATUS_CHOICES = ["active", "discontinued", "backordered"]


@st.cache_resource(show_spinner=False)
def get_client() -> SmallStackClient:
    # One client (one requests.Session) shared across reruns β€” log in once, reuse the token.
    return SmallStackClient(os.environ.get("SMALLSTACK_API_URL", "http://localhost:8050"))


def sidebar_login(client: SmallStackClient) -> bool:
    st.sidebar.title("πŸ“¦ SmallStack")
    st.sidebar.caption("Server-side Streamlit consumer")
    st.sidebar.divider()

    if client.token:                      # we have a token β€” but is it still valid?
        # Validate for real: /me returns the user on success, or an error body on
        # failure (e.g. the backend restarted). Don't trust a bare "we have a token"
        # check, or a dead token silently paints the dashboard with zeros.
        me = client.auth.me()
        if isinstance(me, dict) and me.get("username"):
            st.sidebar.success(f"Signed in as **{me['username']}**")
            st.sidebar.caption(f"API: `{client.base_url}`")
            if st.sidebar.button("Log out", use_container_width=True):
                get_client.clear()        # drop the cached client β†’ fresh, unauthenticated one
                st.rerun()
            return True
        client.set_token(None)            # token rejected β€” drop it, fall through to login
        st.sidebar.warning("Session expired β€” please sign in again.")

    st.sidebar.subheader("Log in")
    with st.sidebar.form("login_form"):
        username = st.text_input("Username", value=os.environ.get("SMALLSTACK_USERNAME", "admin"))
        password = st.text_input("Password", value=os.environ.get("SMALLSTACK_PASSWORD", "admin"), type="password")
        submitted = st.form_submit_button("Log in", use_container_width=True)
    if submitted:
        try:
            client.auth.login(username, password)   # stores the token on the client
            st.rerun()
        except ApiError as exc:
            st.sidebar.error(str(exc))
    return client.token is not None

The whole security story lives here: credentials come from the environment (or the form), get exchanged for a token server-side, and the token stays on the cached client. It never reaches a browser, so there's no client-side storage to leak and no CORS surface to open.


Step 4: Data helpers β€” let the server aggregate

Two cached helpers. The dashboard one asks SmallStack to compute the numbers; the table one fetches a filtered page. Add to app.py:

@st.cache_data(ttl=60, show_spinner=False)
def fetch_aggregates(_client: SmallStackClient) -> dict:
    # The backend does the math β€” we never pull rows to sum them. (_client isn't hashed.)
    # resource().list() RAISES ApiError on a non-2xx response, so a stale token
    # surfaces as an error the caller can handle β€” not a silent bag of zeros.
    items = _client.resource("/api/inventory/items")
    sum_resp = items.list(sum="quantity")
    avg_resp = items.list(avg="unit_price")
    status_resp = items.list(count_by="status")
    return {
        "total_items": sum_resp.get("count", 0),
        "total_quantity": sum_resp.get("sum_quantity", 0),
        "avg_unit_price": avg_resp.get("avg_unit_price"),
        "counts_by_status": status_resp.get("counts", {}) or {},
    }


@st.cache_data(ttl=60, show_spinner=False)
def fetch_items(_client, search, status, page, page_size) -> dict:
    params = {"expand": "category,supplier,bin", "ordering": "name",
              "page": page, "page_size": page_size}
    if search:
        params["q"] = search        # NOTE: search is ?q=, not ?search=
    if status:
        params["status"] = status
    return _client.resource("/api/inventory/items").list(**params)


def _expand_name(value) -> str:
    if isinstance(value, dict):
        return value.get("name") or str(value.get("id", ""))
    return "" if value is None else str(value)

?q= is SmallStack's multi-field search (name/SKU/description at once). Filters (?status=) are exact. Unknown params are silently ignored β€” so ?search= would return everything.

Why resource() and not client.api() here? api() is the low-level escape hatch β€” it returns the parsed body and never raises, so a 401 (say, an expired token after a backend restart) would sail through as {"errors": …} and .get("count", 0) would quietly become 0. resource().list() raises ApiError on any non-2xx, so auth failures surface instead of masquerading as empty data. We lean on that below to bounce the user back to the login screen.


Step 5: The dashboard

Metric tiles from the aggregation calls, plus two st.bar_charts β€” no plotting library, no custom endpoint. Add to app.py:

def render_dashboard(client) -> None:
    st.header("Inventory Dashboard")
    st.caption("Every number below is computed **server-side** by SmallStack's aggregation params.")

    try:
        agg = fetch_aggregates(client)
    except ApiError as exc:
        if exc.status == 401:            # token expired mid-session β†’ recover to login
            client.set_token(None)
            get_client.clear()
            st.rerun()
        st.error(str(exc))
        return

    c1, c2, c3, c4 = st.columns(4)
    c1.metric("Distinct items", f"{agg['total_items']:,}")
    c2.metric("Total quantity on hand", f"{int(agg['total_quantity'] or 0):,}")
    avg = agg["avg_unit_price"]
    c3.metric("Avg unit price", f"${float(avg):,.2f}" if avg else "β€”")
    counts = agg["counts_by_status"]
    c4.metric("Backordered", f"{counts.get('backordered', 0):,}")
    st.caption("`?sum=quantity` Β· `?avg=unit_price` Β· `?count_by=status`")
    st.divider()

    left, right = st.columns(2)
    with left:
        st.subheader("Items by status")
        if counts:
            df = pd.DataFrame({"status": list(counts), "items": list(counts.values())}).set_index("status")
            st.bar_chart(df, color="#4ade80")
    with right:
        st.subheader("Stock value by category")
        rows = fetch_items(client, "", "", 1, 200).get("results", [])
        if rows:
            df = pd.DataFrame({
                "category": [_expand_name(r.get("category")) or "β€”" for r in rows],
                "stock_value": [float(r.get("stock_value") or 0) for r in rows],
            })
            by_cat = df.groupby("category")["stock_value"].sum().sort_values(ascending=False)
            st.bar_chart(by_cat, color="#60a5fa")

Dashboard

The status chart is pure aggregation. The category chart mixes a list read with a small local rollup β€” handy when you want a grouping the API doesn't expose directly (and stock_value, a backend-computed field, comes along for free in each row).


Step 6: The items table

Server-side search + status filter + pagination, rendered with st.dataframe, low-stock rows tinted:

def render_items_table(client) -> None:
    st.header("Items")
    st.caption("Search and status filter run **on the server** via `?q=` and `?status=`.")

    ctrl1, ctrl2, ctrl3 = st.columns([3, 2, 1])
    search = ctrl1.text_input("Search", placeholder="name, SKU, description…")
    status = ctrl2.selectbox("Status", ["(any)"] + STATUS_CHOICES)
    page_size = ctrl3.selectbox("Page size", [10, 25, 50, 100], index=1)
    status_param = "" if status == "(any)" else status
    page = st.session_state.get("items_page", 1)

    resp = fetch_items(client, search, status_param, page, page_size)
    rows = resp.get("results", [])
    if not rows:
        st.info("No items match those filters.")
        return

    df = pd.DataFrame([{
        "SKU": r["sku"], "Name": r["name"], "Category": _expand_name(r.get("category")),
        "Qty": r["quantity"], "Unit $": float(r["unit_price"]),
        "Stock $": float(r.get("stock_value") or 0), "Status": r["status"],
        "Low stock": bool(r.get("is_low_stock")),
    } for r in rows])

    def highlight_low(row):
        c = "background-color: rgba(248,113,113,0.18)" if row["Low stock"] else ""
        return [c] * len(row)

    st.dataframe(df.style.apply(highlight_low, axis=1)
                 .format({"Unit $": "${:,.2f}", "Stock $": "${:,.2f}"}),
                 use_container_width=True, hide_index=True)

    total_pages = resp.get("total_pages", 1) or 1
    n1, n2, n3 = st.columns([1, 2, 1])
    if n1.button("β—€ Prev", disabled=page <= 1, use_container_width=True):
        st.session_state["items_page"] = page - 1; st.rerun()
    n2.markdown(f"<div style='text-align:center'>Page {page} of {total_pages} Β· {resp['count']:,} items</div>",
                unsafe_allow_html=True)
    if n3.button("Next β–Ά", disabled=page >= total_pages, use_container_width=True):
        st.session_state["items_page"] = page + 1; st.rerun()

Step 7: An About page β€” the day-one story

The React, Svelte, and Solid tracks each ship a /about page that explains what SmallStack hands you for free. Give the Python track the same story β€” Streamlit-native, and readable without logging in. Add to app.py:

FEATURES = [
    ("πŸ”Œ Auto REST API", "CRUD, search, filter, expand, paginate, aggregate β€” from one flag."),
    ("πŸ“– Live API docs", "Swagger & ReDoc generated from your models. No YAML to hand-write."),
    ("πŸ—‚οΈ Themed admin", "Dashboard, CRUD pages, 5 palettes Γ— light/dark β€” day one."),
    ("πŸ’“ Status + public page", "Uptime monitoring and a shareable /status page out of the box."),
    ("πŸ“₯ Backups", "Scheduled SQLite snapshots with retention, one command."),
    ("πŸ”Ž Full-text search", "sqlite-fts5 search across your models, indexed automatically."),
    ("🌐 Data explorer", "Browse every registered model in the UI β€” no queries."),
    ("πŸ‘₯ Users & tokens", "Auth, user management, and self-service API tokens included."),
    ("πŸ€– MCP server", "AI tools + OAuth 2.0 at /mcp β€” Claude connects with no glue."),
    ("πŸš€ Kamal + Docker", "Dockerfile, compose, and Kamal deploy with automatic SSL."),
]


def render_about(client) -> None:
    b = client.base_url
    st.header("Streamlit Γ— SmallStack")
    st.markdown(
        "You bring the **frontend** β€” this data app is a few hundred lines of Python. "
        "SmallStack brings **the rest**, day one: auth, admin, docs, uptime, backups, search, "
        "deploy β€” consumed over one REST + JSON seam with a single bearer token.")

    st.divider()
    st.subheader("What ships on day one")
    cols = st.columns(2)
    for i, (name, desc) in enumerate(FEATURES):
        cols[i % 2].markdown(f"**{name}**  \n{desc}")

    st.divider()
    st.subheader("One declaration. Every surface lights up.")
    st.code(
        'class ItemView(CRUDView):\n'
        '    model = Item\n'
        '    fields = ["name", "sku", "quantity", "unit_price", "status"]\n'
        '    search_fields = ["name", "sku"]\n'
        '    enable_api = True   # β†’ REST + OpenAPI docs\n'
        '    enable_mcp = True   # β†’ AI tools at /mcp', language="python")

    st.subheader("See it running β€” this backend, right now")
    st.markdown(
        f"[Swagger]({b}/api/docs/) Β· [ReDoc]({b}/api/redoc/) Β· "
        f"[Data explorer]({b}/smallstack/explorer/) Β· [Public status]({b}/status/) Β· "
        f"[Admin]({b}/smallstack/)")

Step 8: Wire it up and run

Finish app.py with the router. Note the About page is reachable logged-out (public story) and as an ℹ️ About section once you're in:

def main() -> None:
    client = get_client()
    authed = sidebar_login(client)
    st.sidebar.divider()
    st.sidebar.markdown(
        f"- [Swagger docs]({client.base_url}/api/docs/)\n"
        f"- [Items JSON]({client.base_url}/api/inventory/items/)")

    if not authed:
        st.title("SmallStack Β· Streamlit Data App")
        st.info("πŸ‘ˆ Log in from the sidebar. The API token lives on the server, never in a browser.")
        st.divider()
        render_about(client)             # the story is public β€” no login required
        return

    page = st.sidebar.radio("Section", ["πŸ“Š Dashboard", "πŸ“‹ Items", "ℹ️ About"],
                            label_visibility="collapsed")
    if page == "πŸ“Š Dashboard":
        render_dashboard(client)
    elif page == "πŸ“‹ Items":
        render_items_table(client)
    else:
        render_about(client)


if __name__ == "__main__":
    main()

Run it (backend on :8050 first):

uv run streamlit run app.py --server.port 8501

Open http://localhost:8501, log in from the sidebar (admin/admin or demo/demo), and the dashboard loads with server-computed numbers.


What SmallStack gives this Python app

  • Auto REST API β€” inventory endpoints exist with zero view code on this side.
  • Server-side aggregation β€” ?sum=, ?avg=, ?count_by= feed the tiles and status chart; the database does the work, not pandas.
  • Rich query params for free β€” ?q= search, ?status= filter, ?expand= (FK β†’ {id, name}), ?ordering=, ?page=/?page_size= β€” all driven straight from the UI controls.
  • Consistent envelope β€” {count, page, total_pages, next, previous, results} makes the pager trivial.
  • Interactive API docs at /api/docs/, linked from the sidebar.

Security β€” the right way for a server-side app

  • Credentials come from the environment (or the login form), never a literal in source.
  • client.auth.login exchanges them for a token held on the server-side requests.Session.
  • Nothing sensitive touches the browser β€” Streamlit renders HTML server-side.
  • For a real deployment, use st.secrets (.streamlit/secrets.toml) or a secrets manager, and give the app a least-privilege user (the non-admin demo account is a good template) instead of admin.

Tips

  • Search is ?q=, not ?search=. Unknown params are silently dropped, so a wrong param returns everything. Filters (?status=) are exact single-field matches.
  • @st.cache_resource for the client, @st.cache_data for results. The client (and its token) persists across reruns; data is cached by its params with a short TTL.
  • Same backend, many frontends. This exact inventory API also powers the React, Svelte, and Solid tutorials β€” the Python consumer just proves it isn't JavaScript-only.