diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9657afd7..b3713f2c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,11 @@ independently. `flexviz` pins a compatible `flexviz-polars` range.
## [Unreleased]
+### Added
+
+- The Web apps guide shows a dashboard in a Streamlit, Dash, Gradio, or Flask
+ app, on the same port as the app.
+
### Security
- The server sends no CORS headers any more, so a page of another site cannot
@@ -39,6 +44,12 @@ independently. `flexviz` pins a compatible `flexviz-polars` range.
- `flexviz.server.show_server`. Use `Figure.show()`, `Dashboard.show()` or
`flexviz serve`.
+### Fixed
+
+- The `mount_into()` error now tells users of Flask and other WSGI apps to
+ wrap FlexViz with `a2wsgi`. The old hint, a bare `DispatcherMiddleware`,
+ failed on each request with a `TypeError`.
+
## [0.1.0b5] - 2026-09-27
### Added
diff --git a/docs/guides/embedding.md b/docs/guides/embedding.md
index 4470eecc..1b425fe7 100644
--- a/docs/guides/embedding.md
+++ b/docs/guides/embedding.md
@@ -43,8 +43,10 @@ mount_into(app, prefix="/flexviz")
The FlexViz endpoints (`/dashboard/update`, `/share`, `/view`,
`/sources`) then live under the prefix. The mounted app brings its own gzip
middleware, so responses are compressed regardless of the host app's setup.
-For Flask or other WSGI hosts, use
-`werkzeug.middleware.dispatcher.DispatcherMiddleware` instead.
+A WSGI app, such as Flask, cannot mount FlexViz directly, because FlexViz is an
+ASGI app. Wrap FlexViz with `a2wsgi`, as
+[Web apps](web-apps.md#flask-and-other-wsgi-apps) shows. The same guide mounts
+FlexViz into Streamlit, Dash, and Gradio.
## Serving a dashboard from a URL
diff --git a/docs/guides/web-apps.md b/docs/guides/web-apps.md
new file mode 100644
index 00000000..a9c798b0
--- /dev/null
+++ b/docs/guides/web-apps.md
@@ -0,0 +1,309 @@
+# Web apps
+
+You can show a FlexViz dashboard in a Streamlit, Dash, Gradio, or other Python
+web app. The web app owns the page, the widgets, and the layout. FlexViz owns
+the figures and runs their queries on your data. To run FlexViz as a separate
+server instead, see [Embedding](embedding.md).
+
+The procedure is the same for each web framework:
+
+1. Register the data source with `register_source()`.
+2. Mount the FlexViz app on the server of the web app, under `/flexviz`.
+3. Build a `/view` URL with `share_url(server_url="/flexviz", source_name=...)`.
+4. Show the URL in an iframe.
+
+The web app and FlexViz then use one server and one port, so you deploy one
+process. The URL has no host name, so it works on each host that serves the web
+app.
+
+These rules apply to every web framework:
+
+- Give the `Dashboard` the same data as the source: the registered frame, or a
+ lazy scan of the same file.
+- Give `share_url()` the name of the registered source in `source_name`.
+- Use the same prefix in the mount and in `server_url`.
+- If a proxy serves the web app under a path, such as `/app`, put that path in
+ front: `server_url="/app/flexviz"`.
+- Register all sources before the server starts.
+- Do not change the data of a registered source while the server runs. To show
+ new data, restart the server.
+
+A broken rule shows only in the browser. A lazy scan reads no rows until a
+query runs, so the web app can build its `Dashboard` on a scan at no cost. A
+`Dashboard()` without data gives figures without a source name, and each update
+of these figures fails. After a change of a scanned file, the updates also
+fail. With `cache=True`, the first view of each figure can show the old data.
+See [Caching and live brushing](caching-and-live-brushing.md).
+
+## Example data
+
+The examples read `readings.parquet`, a file with a `timestamp` and a `power`
+column.
+
+To make a test file with 2 million rows, run this script one time:
+
+```python
+# make_data.py
+import numpy as np
+import polars as pl
+
+n = 2_000_000
+pl.select(
+ timestamp=pl.datetime(2026, 1, 1) + pl.duration(seconds=pl.int_range(n)),
+ power=pl.Series(np.random.default_rng(0).standard_normal(n).cumsum()),
+).write_parquet("readings.parquet")
+```
+
+## Streamlit
+
+Streamlit 1.57 or newer can add routes to its own server with `st.App`. The
+app then has two files. `app.py` runs one time, when the server starts.
+`page.py` is the Streamlit script, which runs again after each widget change.
+
+Put the source and the mount in `app.py`:
+
+```python
+# app.py
+import flexviz
+import polars as pl
+import streamlit as st
+from starlette.routing import Mount
+
+flexviz.register_source("readings", pl.scan_parquet("readings.parquet"), cache=True)
+app = st.App("page.py", routes=[Mount("/flexviz", app=flexviz.app)])
+```
+
+Put the page in `page.py`:
+
+```python
+# page.py
+import polars as pl
+import streamlit as st
+from flexviz import Dashboard
+
+st.title("Sensor readings")
+bins = st.slider("Histogram bins", 10, 200, 60)
+
+
+@st.cache_data
+def view_url(bins: int) -> str:
+ dashboard = Dashboard(pl.scan_parquet("readings.parquet"), cache=True)
+ dashboard.add_figure(title="Power").add_line(x="timestamp", y="power")
+ dashboard.add_figure(title="Distribution").add_histogram(x="power", bins=bins)
+ return dashboard.share_url(server_url="/flexviz", source_name="readings", cols=1)
+
+
+st.iframe(view_url(bins), height=900)
+```
+
+Start the app:
+
+```bash
+streamlit run app.py
+```
+
+Streamlit finds the `app` object in `app.py`. It then serves the page and
+FlexViz on one port, 8501 by default.
+
+### Keep the view across reruns
+
+A new `Dashboard` gets new uids, so each run of `page.py` gives a new URL. The
+iframe then loads the dashboard again, and the user loses the zoom and the
+selections.
+
+`st.cache_data` prevents this reset. It returns the same URL for the same
+arguments, and Streamlit keeps an iframe whose URL does not change. In the
+example, only a change of `bins` loads the dashboard again.
+
+### Set the height
+
+`st.iframe` cannot measure the height of the dashboard, so it uses 400 px. A
+panel is 400 px by default, and the toolbar is about 45 px. While a selection
+is active, a 45 px bar with the active filters shows at the bottom. The two
+stacked panels in the example thus need 890 px. See
+[Who owns width and height](embedding.md#who-owns-width-and-height).
+
+Set the height of the iframe yourself, as `height=900` does in `page.py`.
+
+## Dash
+
+Dash 4.2 or newer can run on FastAPI.
+
+Install Dash with the FastAPI extra:
+
+```bash
+pip install "dash[fastapi]"
+```
+
+Mount FlexViz on `app.server`:
+
+```python
+# app.py
+import polars as pl
+from dash import Dash, html
+from flexviz import Dashboard, mount_into, register_source
+
+lf = pl.scan_parquet("readings.parquet")
+register_source("readings", lf, cache=True)
+
+dashboard = Dashboard(lf, cache=True)
+dashboard.add_figure(title="Power").add_line(x="timestamp", y="power")
+url = dashboard.share_url(server_url="/flexviz", source_name="readings", cols=1)
+
+app = Dash(__name__, backend="fastapi")
+mount_into(app.server, prefix="/flexviz")
+app.layout = html.Iframe(
+ src=url,
+ title="Sensor readings dashboard",
+ style={"width": "100%", "height": "460px", "border": 0},
+)
+
+if __name__ == "__main__":
+ app.run()
+```
+
+Start the app with `python app.py`.
+
+The URL is a plain string, so a Dash callback can return a new one to
+`html.Iframe.src`.
+
+On the default Flask backend, or before Dash 4.2, apply the
+[Flask recipe](#flask-and-other-wsgi-apps) to `app.server`.
+
+## Gradio
+
+Gradio mounts into a FastAPI app with `gr.mount_gradio_app`.
+
+Mount FlexViz on the same FastAPI app, before Gradio:
+
+```python
+# app.py
+import gradio as gr
+import polars as pl
+import uvicorn
+from fastapi import FastAPI
+from flexviz import Dashboard, mount_into, register_source
+
+lf = pl.scan_parquet("readings.parquet")
+register_source("readings", lf, cache=True)
+
+dashboard = Dashboard(lf, cache=True)
+dashboard.add_figure(title="Power").add_line(x="timestamp", y="power")
+url = dashboard.share_url(server_url="/flexviz", source_name="readings", cols=1)
+
+with gr.Blocks() as demo:
+ gr.HTML(
+ f''
+ )
+
+app = FastAPI()
+mount_into(app, prefix="/flexviz")
+app = gr.mount_gradio_app(app, demo, path="/")
+
+if __name__ == "__main__":
+ uvicorn.run(app, host="127.0.0.1", port=7860)
+```
+
+!!! warning "Mount FlexViz before Gradio"
+ Gradio at `path="/"` answers every path that no earlier route answers. If
+ you mount Gradio first, `/flexviz/view` returns 404.
+
+## Other web frameworks
+
+### FastAPI and Starlette apps
+
+A web framework that runs on FastAPI or Starlette takes the same mount. For
+example, the `app` of NiceGUI is a FastAPI app, so
+`mount_into(app, prefix="/flexviz")` adds FlexViz to it.
+
+### Flask and other WSGI apps
+
+A WSGI app, such as Flask, cannot mount FlexViz directly, because FlexViz is an
+ASGI app. The [a2wsgi](https://pypi.org/project/a2wsgi/) package wraps FlexViz
+as a WSGI app. The example leaves out the source and the `url`, which are the
+same as in the Dash example.
+
+Install a2wsgi:
+
+```bash
+pip install a2wsgi
+```
+
+Mount the wrapped app with the dispatcher of Werkzeug:
+
+```python
+import flexviz
+from a2wsgi import ASGIMiddleware
+from flask import Flask
+from werkzeug.middleware.dispatcher import DispatcherMiddleware
+
+app = Flask(__name__)
+app.wsgi_app = DispatcherMiddleware(
+ app.wsgi_app, {"/flexviz": ASGIMiddleware(flexviz.app)}
+)
+
+
+@app.get("/")
+def index():
+ return (
+ f''
+ )
+```
+
+Start the app with `flask --app app run`.
+
+## Limits
+
+- The Python code of the web app cannot read the zoom or the selections in the
+ dashboard. The **Share** button gives a URL with the full view.
+- The server always queries the registered source. A filter on the frame of
+ the `Dashboard`, such as `lf.filter(...)`, thus has no effect.
+- A subset of the data needs its own registered source. A widget can then
+ select it through `source_name`.
+- A widget can change the spec: data columns, traces, bins, or layout. A
+ selection in a FlexViz figure filters the rows of the other figures.
+- A new URL loads the dashboard again, and the view goes back to its start.
+
+## Security
+
+The FlexViz routes have no authentication. Each user who can open the web app
+can query the registered sources through `/flexviz`. The mount also serves
+`/flexviz/h/N`, which reads the agent history file `.flexviz/history.jsonl` in
+the working folder of the server. See the
+[safety notes for agents](ai-agents.md#safety-notes).
+
+Streamlit listens on all network interfaces by default. The `Host` check of
+`show()` and `flexviz serve` does not apply to a mounted app.
+
+Before you deploy the web app:
+
+- Put authentication in front of it. Make sure that the authentication also
+ covers the `/flexviz` routes.
+- Do not run it from a folder that holds `.flexviz/history.jsonl`.
+
+For local use:
+
+- Start Streamlit with `streamlit run app.py --server.address 127.0.0.1`.
+- Wrap FlexViz in Starlette's `TrustedHostMiddleware`, as the example that
+ follows shows.
+
+```python
+import flexviz
+from starlette.middleware.trustedhost import TrustedHostMiddleware
+
+guarded = TrustedHostMiddleware(flexviz.app, allowed_hosts=["localhost", "127.0.0.1"])
+```
+
+Mount `guarded` in place of `flexviz.app`:
+
+| Web framework | Mount |
+| ------------- | ---------------------------------------------------------- |
+| Streamlit | `Mount("/flexviz", app=guarded)` in the routes of `st.App` |
+| Dash | `app.server.mount("/flexviz", guarded)` |
+| Gradio | `app.mount("/flexviz", guarded)`, before Gradio |
+| Flask | `ASGIMiddleware(guarded)` in the `DispatcherMiddleware` |
+
+The middleware refuses a request for another host name, which blocks DNS
+rebinding.
diff --git a/docs/index.md b/docs/index.md
index c14a536b..abf9b0fd 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -192,5 +192,7 @@ and `add_geo_line`. See the [Figure API](api/figure.md) for every parameter.
caching for static data and zero-latency brushing.
- [Embedding](guides/embedding.md): mount FlexViz into an existing FastAPI
app.
+- [Web apps](guides/web-apps.md): show a dashboard in a Streamlit, Dash, or
+ Gradio app.
- [Agents](guides/ai-agents.md): drive FlexViz from a coding agent and read
back what the human explored.
diff --git a/flexviz/server.py b/flexviz/server.py
index 0415d5ca..10e84632 100644
--- a/flexviz/server.py
+++ b/flexviz/server.py
@@ -736,9 +736,9 @@ async def dashboard_update(
def mount_into(host_app: Any, prefix: str = "/flexviz") -> None:
"""Mount the flexviz FastAPI app on *host_app* under *prefix*.
- Works with FastAPI / Starlette (``host_app.mount``). For Flask/WSGI
- hosts, use ``werkzeug.middleware.dispatcher.DispatcherMiddleware``
- instead.
+ Works with FastAPI / Starlette (``host_app.mount``). A Flask/WSGI host
+ cannot call an ASGI app: wrap ``app`` in ``a2wsgi.ASGIMiddleware`` and
+ mount that with ``werkzeug.middleware.dispatcher.DispatcherMiddleware``.
The mounted app carries its own ``GZipMiddleware(minimum_size=1024)``
(cube cross-filter design §8.1 — the wire-size mitigation), so all
@@ -758,8 +758,9 @@ def mount_into(host_app: Any, prefix: str = "/flexviz") -> None:
else:
raise TypeError(
f"host_app of type {type(host_app).__name__!r} does not support "
- ".mount(). Use werkzeug.middleware.dispatcher.DispatcherMiddleware "
- "for Flask/WSGI hosts."
+ ".mount(). For Flask/WSGI hosts, wrap flexviz.app in "
+ "a2wsgi.ASGIMiddleware and mount that with "
+ "werkzeug.middleware.dispatcher.DispatcherMiddleware."
)
diff --git a/mkdocs.yml b/mkdocs.yml
index ae5a2116..e054df03 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -79,6 +79,7 @@ nav:
- Data sources: guides/data-sources.md
- Sharing views: guides/sharing.md
- Embedding: guides/embedding.md
+ - Web apps: guides/web-apps.md
- Agents: guides/ai-agents.md
- API reference:
- Figure: api/figure.md
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 66c20d31..91190b75 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -1714,7 +1714,7 @@ def test_mount_routes_accessible(self):
def test_mount_raises_for_non_asgi(self):
from flexviz.server import mount_into
- with pytest.raises(TypeError, match="does not support .mount"):
+ with pytest.raises(TypeError, match=r"does not support \.mount.*a2wsgi"):
mount_into(object(), "/fv")