You Can Write Backend Driven Web Apps in Marimo Notebooks Using Datastar
This post accompanies a video I recorded on the subject: Marimo and Datastar (with appearances by HTPy and sanic).
Introduction
I love notebook programming environments. They tap into the concept of literate programming that can make programming fun like a videogame. Execute code, see result. The more interactive, the more fun.
I've always desperately wanted to use them to write fully fledged programs, but have always found these environments to be lacking.
Mostly it exists as a disconnect between what you write in the notebook and what the fully realized program looks like. You write your code in the notebook environment, but an export is required to place the code where it ends up being executed. This is a build step, and creates a disconnect between executing code and seeing the result, the entire point of notebook environments.
What is Marimo?
Marimo is a relatively new python notebook environment that aims to solve a few of the problems outlined above. Notably, the files underlying Marimo notebooks are plain python files and can be executed as such. It is also possible to write Marimo notebooks in such a way that functions or classes you define within the notebook can be imported from other python files or Marimo notebooks just as normal.
What is Datastar?
Datastar is a new frontend library designed to make hypermedia driven applications simple. It uses the concept of signals to transfer state between the frontend and backend, (though it can use forms too), and works best when you embrace the idea of rendering your HTML on the backend and sending it down to be merged into the DOM.
Putting Marimo and Datastar Together
Marimo, and python notebooks in general, are just frontends to a python process running somewhere. What's special about Marimo is that it takes full advantage of the interactive capability of the browser and channels it into changing state in python land.
How Marimo Does Reactivity
When you move a slider in a Marimo notebook, an underlying python variable's value is affected. Whenever Marimo detects that a dependency of one cell has changed, it reactively runs any dependent cells.
You can see this behavior below, powered by Marimo snippets:
import marimo as mo
slider = mo.ui.slider(1, 10)
slider
slider.value * "🍃"
When the slider above is changed, the slider's underlying value is changed, which causes the cell with slider.value * "🍃"
to update.
How Datastar Does Interactivity
While marimo affects python variables, datastar deals with its own state management in the form of what it calls "signals." Signals can help with frontend state, and whenever you make a request with datastar's action plugins, all non-private signals are sent to the endpoint.
Here's the code wherein we use datastar's attributes to set the text of the above div to the computed value of the $multiplier
(slider) multiplied by 🍃, the final value of which is stored in the $leaves
signal:
<input min="1" max="10" value="1" type="range" data-bind-multiplier />
<div data-computed-leaves="'🍃'.repeat($multiplier)" data-text="$leaves"></div>
As you can see, datastar only requires data-*
attributes to be set inside the markup to work. Wherever we can render HTML, we can make use of datastar. Since marimo is just a web page, that means we can also use it there! (Assuming marimo has the ability to import and run arbitrary javascript, which it does!)
Rendering HTML Inside Marimo Using HTPy
But hold on! How are we going to write HTML inside a python notebook without a commonly used templating library like Jinja, which typically requires separate template files?
This is where HTPy comes in, a python library that allows you to write html markup directly with python. This library and others like it1 are perfect for marimo! There's even one written specifically for marimo called mohtml2.
To render HTPy's output inside marimo, we need to implement the _display_
method, a special method marimo uses for displaying rendered content.
import marimo as mo
from htpy import Element, VoidElement, div
def setup_rendering():
def render(self):
return mo.Html(self.__html__())
Element._display_ = render
VoidElement._display_ = render
setup_rendering()
div["Hello, world!"]
Now whenever the last evaluated expression of a cell is an HTPy function, marimo will render the output!
Another cool feature of using this approach is the ability to render HTML that's dependent upon reactive variables, for example:
text_size = mo.ui.slider(start=1, stop=5, value=1)
mo.hstack(("Text size: ", text_size), justify="start")
div(style=f"font-size: {text_size.value}rem")["Hello, world!"]
import marimo as mo
from htpy import Element, VoidElement, div
def setup_rendering():
def render(self):
return mo.Html(self.__html__())
Element._display_ = render
VoidElement._display_ = render
setup_rendering()
Every time the text_size
variable's value is changed via the slider, cells that depend on its value get re-evaluated, meaning the HTPy div
function's output is re-rendered.
You can use this capability to update your UI on the fly, shifting different markup attributes (not limited to CSS styles) to your liking.
Using Datastar Inside a Marimo Notebook
All we have to do is create a suitable .html
file that includes datastar as a script, either hosted locally or from a CDN. And since we can render HTML from python using HTPy, we can do this directly inside marimo!
def ds_script():
return script(
type="module",
src="https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js",
)
def create_head():
with open("head.html", "w") as f:
f.write(ds_script().__html__())
with open("head.html", "r") as f:
contents = f.read()
print(contents)
create_head()
To use the head.html
file that this creates, we use marimo's capability to import arbitrary files into the <head>
section of its frontend.
After it's imported, any HTML we render inside marimo that uses data-*
attribute will automatically be picked up on by Datastar.
At this point, we would have the access to the power Datastar while holding to exclusively frontend signals, but we can go further. We can create functions to generate HTML inside marimo, import those functions into a web app created in another python file, and then hook up what we see in our Marimo notebook to our backend.
Backend Reactivity in Marimo's Frontend
To put it simply, we're hosting our web app, and then hooking up the rendered view inside marimo to this web app via it API. In my example repo and as demonstrated in the video I recorded on the subject, I create a Sanic app, pull in components created in marimo, and when the web app is online, the components rendered in Marimo behave exactly the same way as they would on a real web page.
Note that we need to do some CORS configuration to enable access to our web app from Marimo. In my Sanic demo I use Sanic Extensions to add localhost
to acceptable origins.
flowchart TB %% --- LEFT SIDE: MARIMO NOTEBOOK --- subgraph MARIMO_NOTEBOOK["MARIMO NOTEBOOK\nmodel-view"] direction TB viewDef["defines view()
HTPY View Function"] callView["call view()
Shows user count"] viewDef --> callView end %% --- RIGHT SIDE: SANIC WEB APP --- subgraph SANIC["SANIC WEB APP\ncontroller"] direction TB endpointRoot["/ (index)
Simply returns rendered view"] endpointUpdates["/updates
Returns EventStream (awaits signals)"] endpointRoot -->|signal on call| endpointUpdates end %% --- CROSS-BOUNDARY RELATIONSHIPS --- hypotheticalModel["Hypothetical Model"] -.-> viewDef style hypotheticalModel stroke-dasharray:5,5 viewDef -->|imported| endpointRoot callView -->|GETs on-load| endpointUpdates
Demo Implementation Detail: An Event Bus
The demo implements a pattern that Datastar's design encourages: an Event Bus pattern.
Rather than the classical request/response pattern, we do not make a meaningful response directly from the handler. Instead we try to do some work and send a signal indicating whether the work succeeded or failed. There may or may not be a subscriber that handles the emitted signal. Since Datastar has a limited vocabulary of responses it can understand, we can implement a generic handler /updates
that will take any Datastar valid command (such as merge-fragments
or merge-signals
) and send it down to whichever sender is relevant.
With this design, we get multiplayer for free as a consequence of all updates being global by default. We can restrict these updates by session IDs or some more sophisticated grouping mechanism. This is all a consequence of using browser/HTTP specification compliant features.
Frontend, Backend, Which End is Up?
If you're targeting a hypermedia client (the browser), then your state should ultimately live on a hypermedia server (your HTTP framework of choice). Frontend interactivity should be the medium by which your user alters that state. With Marimo, you get a very cool environment in which to inspect and perform live changes to that medium. All you need is a light shim to handle state transfer between the backend and frontend, and Datastar is that perfect shim3.
A Fully Reactive Web App Development Environment
In terms of the Model-View-Controller paradigm, we're able to write both the model and view directly inside a Marimo notebook, and interact with them in the same way that we would within our web app. This unlocks a lot of possibilities, and brings us almost fully to the realization of the desire I expressed above: to make notebooks into a videogame; execute code, see result, now with the fullness of distribution the browser allows for.
Additional Features Marimo Gets Us
Marimo also has a whole host of capabilities when it comes to writing SQL and interacting with databases. For example, you could definitely inspect the contents of your production database from within Marimo (carefil!), get the results of queries into a dataframe, and create meaningful visualizations based on your real data!
Think this is cool? Think this is stupid? Let me know on 𝕏:
Yo dawg, I heard you like interactivity, so I put interactive signals in your reactive notebook environment so you can interact with your backend while you reactively update your rendered frontend. pic.twitter.com/2nbGJm43q3
— Lucian (@LucianKnock) June 6, 2025
-
These include fasthtml (a fullstack library), tagflow (uses context managers and decorators instead of nested function calls), and more. ↩
-
I have elected not to use this so far because my intention is for the markup-using functions I write to work as well inside and outside marimo, and because the library has a dependency on BS4. ↩
-
It's possible to do the same thing with HTMX, another hypermedia library, but I find Datastar to be a much simpler and more powerful framework. ↩