← All guides

Mock a REST API for Streamlit β€” build the data app before the backend exists

Streamlit apps are usually the front of something: a data API a platform team is still building, an internal service you can only reach on the VPN, a third-party API with a rate limit that hates your rerun-the-whole-script-on-every-widget-change execution model. st.connection has you covered for SQL and Snowflake β€” but when the data source is a REST API, you're on plain requests, and you need that API to exist, tolerate a whole team hammering it, and be able to act broken on demand so your spinner, error and empty states aren't designed in production.

The boring fix is a hosted mock API. Every snippet below was executed verbatim against the live service before publishing β€” through Streamlit's own AppTest harness plus a real streamlit run browser check (details in the verified note).

1. A working dashboard in 15 lines (10 seconds, no signup)

The public demo project ships 30 seeded products. Point requests + pandas at it:

# app.py  β€”  pip install streamlit pandas requests  β†’  streamlit run app.py
import pandas as pd
import requests
import streamlit as st

BASE = "https://mockbird.mockbird.workers.dev/m/demo"

st.title("Product dashboard")

rows = requests.get(f"{BASE}/products", params={"limit": 100}, timeout=10).json()
df = pd.DataFrame(rows)

st.metric("Products", len(df))
st.metric("Average price", f"${df['price'].mean():.2f}")
st.bar_chart(df.groupby("category")["price"].mean())
st.dataframe(df[["name", "price", "category", "inStock", "rating"]])

That renders two st.metric cards (30 products, average price), a per-category bar chart and a sortable table β€” against a URL that also works from your teammate's laptop, a notebook, and CI. Want your own data instead of our demo? One curl:

curl -X POST "https://mockbird.mockbird.workers.dev/api/projects?preset=ecommerce"

copy baseUrl from the response, change BASE, done. Or create it in one click.

🐼 pandas one-liner: every list endpoint also speaks CSV, so you can skip the JSON wrangling: pd.read_csv("https://mockbird.mockbird.workers.dev/m/demo/products?mock_format=csv&select=name,price,category&limit=100", storage_options={"User-Agent": "my-streamlit-app"}) β†’ a typed 30Γ—4 DataFrame (we ran it). The storage_options part matters: pandas fetches URLs with Python's default urllib User-Agent, which Cloudflare's edge blocks platform-wide with a 403 (documented gotcha) β€” any custom UA fixes it.

2. Your CSV is already an API

Half of all Streamlit apps start life as pd.read_csv("some_file.csv"). Promote the file to a real typed REST API instead β€” one curl, no signup:

printf 'name,role,salary,remote
Ada Lovelace,Engineer,185000,true
Grace Hopper,Admiral,190000,false
Katherine Johnson,Analyst,170000,true
' | curl -X POST --data-binary @- -H "content-type: text/csv" \
  "https://mockbird.mockbird.workers.dev/api/projects/import?resource=people&name=team-api"

Columns are typed from the data (salary is a number, remote a boolean β€” we verified both), so the query toolkit works immediately: ?salary_gte=180000 returns exactly the two high earners, ?remote=true the two remote folks, plus sortBy/order/_page/q= full-text search. Full details in the CSV β†’ REST API guide.

3. A form that really writes

Public fake APIs (JSONPlaceholder, FakeStoreAPI, DummyJSON) fake their writes β€” POST answers 201 and the record was never stored, so an st.form demo quietly lies to you. Here writes persist. Using the people project from Β§2 (swap in your project id):

import pandas as pd
import requests
import streamlit as st

BASE = "https://mockbird.mockbird.workers.dev/m/YOUR_PROJECT_ID"

st.title("Team dashboard")

rows = requests.get(f"{BASE}/people", params={"sortBy": "salary", "order": "desc"},
                    timeout=10).json()
st.dataframe(pd.DataFrame(rows))

st.subheader("Add someone")
with st.form("add_person", clear_on_submit=True):
    name = st.text_input("Name")
    role = st.text_input("Role")
    salary = st.number_input("Salary", min_value=0, step=1000)
    remote = st.checkbox("Remote")
    if st.form_submit_button("Add"):
        r = requests.post(f"{BASE}/people",
                          json={"name": name, "role": role,
                                "salary": salary, "remote": remote},
                          timeout=10)
        r.raise_for_status()
        st.rerun()   # re-fetch β€” the new row is really there

We ran exactly this app through AppTest: filled the form, submitted, and the table re-rendered with 4 rows β€” then fetched /people/4 with plain requests from outside Streamlit and got Annie back. It's a real record on a real URL, visible to anyone you share the link with.

4. Real loading states β€” design your spinner honestly

On localhost every fetch resolves in milliseconds, so nobody ever sees the spinner until the app is deployed and the API is slow. Ask the mock to be slow β€” and cache the result the way you would in production:

import requests
import streamlit as st

BASE = "https://mockbird.mockbird.workers.dev/m/demo"

@st.cache_data(ttl=60)
def load_products(delay_ms=0):
    r = requests.get(f"{BASE}/products",
                     params={"mock_delay": delay_ms}, timeout=15)
    r.raise_for_status()
    return r.json()

with st.spinner("Loading products… (the API is taking 3 s on purpose)"):
    rows = load_products(delay_ms=3000)

st.success(f"{len(rows)} products loaded")

Measured when we ran it: the first run took 4.0 s (a real 3 s server-side delay plus network β€” the spinner is genuinely on screen), the second run 0.12 s, because st.cache_data served it without touching the network. That's the whole loading story β€” spinner, slow API, cache recovery β€” exercised before a single user sees it.

5. The error branch you never test

import requests
import streamlit as st

BASE = "https://mockbird.mockbird.workers.dev/m/demo"
FORCE_STATUS = 500   # flip to 0 to load normally

params = {"limit": 100}
if FORCE_STATUS:
    params["mock_status"] = FORCE_STATUS

r = requests.get(f"{BASE}/products", params=params, timeout=10)
if r.ok:
    st.dataframe(r.json())
else:
    st.error(f"The products API answered {r.status_code}. Showing nothing "
             "is better than showing something wrong.")
    st.button("Retry")   # any click reruns the script top to bottom

?mock_status=500 makes the same endpoint answer a real HTTP 500 (any code 100–599 works: 401 for expired sessions, 429 for rate limits, 503 for maintenance pages). Your st.error branch, designed on purpose, with a Retry button β€” clicking any button reruns a Streamlit script, so retry is free.

6. Deterministic retry drills

Chaos that fails randomly is bad for demos. ?mock_seq=500,500,200 serves a deterministic sequence: first request a real 500, second a real 500, third (and after) the real data β€” so a retry loop provably earns its keep:

import requests
import streamlit as st

BASE = "https://mockbird.mockbird.workers.dev/m/demo"

@st.cache_data(ttl=30)
def load_with_retry(attempts=3):
    for attempt in range(1, attempts + 1):
        r = requests.get(f"{BASE}/products",
                         params={"limit": 5, "mock_seq": "500,500,200",
                                 "mock_seq_key": "streamlit-retry-demo"},
                         timeout=10)
        if r.ok:
            return attempt, r.json()
    r.raise_for_status()

attempts, rows = load_with_retry()
st.info(f"Loaded {len(rows)} products on attempt {attempts} of 3")

Run fresh, the app reports β€œLoaded 5 products on attempt 3 of 3”. We verified the mechanism precisely: four raw requests with a fresh key answered 500, 500, 200, 200 with x-mockbird-seq: 1/3 … 3/3 headers. On the shared demo the counter is scoped per visitor IP, so this snippet works for every reader independently; once a sequence is spent it sticks on the last entry β€” add &mock_seq_reset=1 to one request to run the drill again.

7. The empty state, on demand

import requests
import streamlit as st

BASE = "https://mockbird.mockbird.workers.dev/m/demo"

rows = requests.get(f"{BASE}/products",
                    params={"mock_snapshot": "empty"}, timeout=10).json()

if rows:
    st.dataframe(rows)
else:
    st.info("No products yet β€” this is your empty state. Design it on purpose.")

?mock_snapshot=empty serves the same endpoint read-only from a saved snapshot β€” the demo ships empty and edge-cases (100-char names, unicode, price 0) built in. On your own project you can save any number of named scenarios and pin each demo tab, teammate, or test to a different one, without touching live data.

8. Test the app with AppTest β€” pinned to frozen data

Streamlit's built-in AppTest runs your script headlessly and lets you assert on rendered elements. One thing it does not do is mock your HTTP calls β€” we proved it: an AppTest run of an app fetching with ?mock_delay=2000 took 3.1 s of wall clock. Your tests hit the real network, so point them at data that can't drift:

# test_dashboard.py  β€”  pip install pytest  β†’  pytest test_dashboard.py
from streamlit.testing.v1 import AppTest


def test_dashboard_renders_products():
    at = AppTest.from_file("apps/dashboard.py", default_timeout=15)
    at.run()
    assert not at.exception
    assert at.metric[0].value == "30"          # demo ships 30 products
    assert len(at.dataframe[0].value) == 30


def test_empty_state_branch():
    at = AppTest.from_file("apps/empty.py", default_timeout=15)
    at.run()
    assert not at.exception
    assert "empty state" in at.info[0].value


def test_error_branch():
    at = AppTest.from_file("apps/errors.py", default_timeout=15)
    at.run()
    assert not at.exception
    assert "500" in at.error[0].value
    assert at.button[0].label == "Retry"

3 passed in 5.24s when we ran it against production. The first test can assert == "30" only because the demo reseeds to exactly 30 products β€” on your own project, pin tests to a named snapshot so a colleague's writes can't flake your CI. (Note default_timeout: AppTest's default is 3 s, which a deliberately slow mock will exceed.)

9. Honest comparison

Hard-coded CSV / fixture filesresponses / requests-mockMockbird
What it ispd.read_csv("fixture.csv") in the appPatches requests in-process (tests only)Hosted mock API
App code is production-shaped (fetches a URL)βœ– rewrite before shipβœ” in tests, βœ– for streamlit runβœ” swap one base URL to ship
Works offline / zero latencyβœ”βœ”βœ– (real network)
Writes persist (forms, st.data_editor save-backs)βœ–βœ–βœ”
Loading / error / retry states are realβœ–Simulated at bestβœ” mock_delay/mock_status/mock_seq
Same data visible to teammates, CI, the browserFiles drift apartβœ– one processβœ” one https URL
Free tierfreefree (library)free: 20 projects, 10k req/project/day

Use all three where they're strongest: fixtures for offline sketching, responses for millisecond unit tests of helper functions, and a hosted URL the moment the app needs to look and fail like production. More on the Python testing side (retry adapters vs chaos, httpx, pytest fixtures): mock APIs for Python. Building dashboards in Grafana instead? The Grafana / Infinity data source version of this guide. Building an ML demo in Gradio? The Gradio version β€” including a mocked POST /predict with GPU-ish latency. Working in R/Shiny instead? The R version β€” httr2, data.frames both directions, and testServer-verified Shiny. Doing the analysis in a bare notebook first? The pandas/Jupyter version β€” the read_json 403 trap in full, pagination loops, and df.to_csv round-tripped into your own typed API.

10. Ship day

Mockbird follows plain REST conventions, so the swap to the real backend is one line β€” put the base URL in st.secrets and read BASE = st.secrets["api_base"]. Until then, hand your platform team the contract while they build it: /openapi.json, a Postman collection and TypeScript types are generated from your live schema.

Verified: every snippet on this page was executed verbatim against the production service on Sep 20, 2026 β€” through Streamlit 1.64's own AppTest harness (30 assertions: rendered metrics/tables/branches, measured spinner + cache timings, deterministic mock_seq headers, the form write read back from outside Streamlit) plus a real streamlit run render check in a real browser. The harness substitutes exactly two things: a self-test header injected at the transport level so our own runs are excluded from usage stats, and the placeholder project id in Β§3.

Also mocking for a frontend or another service? The same project feeds React, Vue, Flutter, Python, FastAPI, Flask, Django and Jest at once β€” one dataset for the whole team. Docs β†’
⚑ Skip the terminal: this link creates a live, seeded backend (products, orders, customers, reviews) in the dashboard β€” real URL, no signup. Or import your own CSV, OpenAPI spec, db.json, Postman collection, or HAR and mock your exact shapes.