Coming from Streamlit
A Streamlit app is one Python process holding your session: the UI, the state, and the compute are the same object. That's what makes the first hour so good, and it's the single fact behind every wall you eventually hit.
n6k splits that object in two. Data is a data.py that publishes tables; the
view is Markdown or plain React that reads those tables by name. Nothing
else connects them, so the view can be lifted into an app you already have.
The same app, both ways
A category filter, three KPIs, and a revenue-by-month chart.
Streamlit — one file, app.py
import pandas as pd
import streamlit as st
@st.cache_data
def load():
return pd.read_parquet("orders.parquet")
orders = load()
category = st.selectbox(
"Category", ["All", "Hardware", "Software", "Services"]
)
if category != "All":
orders = orders[orders.category == category]
c1, c2, c3 = st.columns(3)
c1.metric("Revenue", f"${orders.amount.sum():,.0f}")
c2.metric("Orders", f"{len(orders):,}")
c3.metric("Avg order", f"${orders.amount.mean():,.2f}")
by_month = (
orders.assign(month=orders.order_date.dt.strftime("%Y-%m"))
.groupby("month", as_index=False)["amount"].sum()
)
st.line_chart(by_month, x="month", y="amount")n6k — data.py
import pandas as pd
from n6k_app import Database
db = Database()
@db.data()
def orders() -> pd.DataFrame:
return pd.read_parquet("orders.parquet")
@orders.view()
def revenue_by_month() -> str:
return """
SELECT strftime(order_date, '%Y-%m') AS month,
category,
sum(amount) AS revenue,
count(*) AS orders
FROM orders
GROUP BY 1, 2
"""n6k — page.mdx
::Select[category]{options="All,Hardware,Software,Services" default="All"}
<Grid columns={3}>
<Metric
label="Revenue"
query={`SELECT sum(revenue) FROM db.revenue_by_month ${where(category)}`}
/>
<Metric
label="Orders"
query={`SELECT sum(orders) FROM db.revenue_by_month ${where(category)}`}
/>
<Metric
label="Avg order"
query={`SELECT sum(revenue)/sum(orders) FROM db.revenue_by_month ${where(category)}`}
/>
</Grid>
<LineChart
query={`SELECT month, sum(revenue) AS revenue
FROM db.revenue_by_month ${where(category)}
GROUP BY 1 ORDER BY 1`}
x="month"
y="revenue"
/>Same length, roughly. The difference isn't how much you write — it's that the right-hand column is two files that don't know about each other, and only one of them is Python.
It's running right here
This is that page, on this page. data.py is in this folder; the tags below are
the ones printed above.
What maps to what
| Streamlit | n6k |
|---|---|
| script, top to bottom | data.py + page.mdx |
@st.cache_data | @db.data() |
| a pandas transform | @source.derive() |
| filtering a DataFrame in code | @source.view() — SQL, run in the browser |
| a function behind a widget | @db.rpc() — called with live arguments |
st.session_state | page state: in frontmatter |
st.selectbox / st.slider | ::Select / ::Slider |
st.dataframe | <Table> |
st.line_chart(df) | <LineChart> naming a table |
| a multipage app | a folder of pages |
| a custom component (iframe) | your own React, in the page's own DOM |
Where the rerun goes
Move the filter above. Nothing happens in Python.
In Streamlit, changing that selectbox re-runs the script from the top. Cached
loads are skipped, but the filter, the three aggregates, and the groupby all run
again on the server, and the result travels back to the browser. Every
interaction is a round trip whose cost scales with how much of your script sits
below the widget.
In n6k, revenue_by_month was computed once, when the page loaded. The filter
rewrites a SQL query that DuckDB-WASM runs in the tab, against data already
there. No server, no round trip, no rerun — because there's no render loop on
the server to re-enter.
That line moves when you want it to. An @db.rpc() is the explicit "this call
goes to Python" — a scikit-learn fit, a live stream — and the surrounding
SELECT still runs in the browser. What changes is that the round trip is a
thing you asked for, rather than the default cost of every widget.
The wall
Four places a Streamlit app stops being the right shape, and where each one lands here:
-
Auth. Streamlit has no user; the app is the session. Since permissions can't hang off anything, they end up as
ifstatements in your script — which means the data was already loaded before you decided who was allowed to see it. Here they live on the connection: see row-level security over Postgres, where the server scopes every read to the logged-in identity and no SQL from the browser can widen it. -
Your own frontend. Streamlit renders Streamlit. A custom component is a sandboxed iframe that can't reach your CSS or your component library. Here the view is React you wrote — see the same app as plain frontend and a bespoke React page over the same
data.py. -
Scaling. Because the process holds your session, scaling means sticky sessions and one Python worker per concurrent reader. Here the interactive work is in the browser: readers scale like a static site, and Python only runs when an RPC is actually called.
-
Nothing else can call it. A Streamlit app is a rendered page and nothing more — there's no way to get the numbers out except by looking at them. An n6k page's tables are a real catalog: query them from your terminal, a notebook, or any DuckDB client. See attaching a page from anywhere.
The Coming from Streamlit track builds one Monte Carlo forecast three times:
as a script with the compute in the render loop, then with a data.py, then as
pure frontend. It's the same migration, shown one move at a time.