on GitHub

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")

n6kdata.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
    """

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

Revenue
Orders
Avg order
Revenue by month
Loading…

What maps to what

Streamlitn6k
script, top to bottomdata.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_statepage state: in frontmatter
st.selectbox / st.slider::Select / ::Slider
st.dataframe<Table>
st.line_chart(df)<LineChart> naming a table
a multipage appa 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 if statements 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.

Want the long version?

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.