diff --git a/README.md b/README.md
index 5164fcd..7f947dd 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,9 @@ Run `make help` to see all targets.
React and Shiny have been brought together many times, in several distinct
shapes. The list below is roughly ordered from "closest to `shinyreact`" to
-"solves a different problem," so you can see where this repo sits.
+"solves a different problem," so you can see where this repo sits. For a
+side-by-side comparison, in particular with the similarly named
+`shiny.react`, see the [comparison article](https://posit-dev.github.io/shinyreact/articles/comparison.html).
* Whole-frontend-in-React approaches (same shape as the `ui.tsx` pattern)
@@ -85,7 +87,9 @@ shapes. The list below is roughly ordered from "closest to `shinyreact`" to
- **[react-R/reactR](https://github.com/react-R/reactR)**: (R) Scaffolding (`scaffoldReactWidget()`, `scaffoldReactShinyInput()`, `createReactShinyInput()`) for authoring htmlwidgets and Shiny inputs whose implementation is a React component. `rstudio::conf(2019)` talk [*Integrating React.js and Shiny*](https://posit.co/resources/videos/integrating-react-js-and-shiny/) and the [*Outstanding User Interfaces with Shiny*](https://unleash-shiny.rinterface.com/going-further-reactr) chapter.
- - **[Appsilon/shiny.react](https://github.com/Appsilon/shiny.react)** & **[Appsilon/shiny.fluent](https://github.com/Appsilon/shiny.fluent)**: (R) A generic toolbox for wrapping React component libraries as R functions; `shiny.fluent` is the flagship consumer, exposing Microsoft's Fluent UI to R.
+ - **[glin/reactable](https://github.com/glin/reactable)**: (R) Interactive data tables built on React Table with `reactR`; the best-known `reactR` consumer.
+
+ - **[Appsilon/shiny.react](https://github.com/Appsilon/shiny.react)**: (R) A generic toolbox for wrapping React component libraries as R functions — the UI stays authored in R, the inverse of `shinyreact`. **[shiny.fluent](https://github.com/Appsilon/shiny.fluent)** (Microsoft Fluent UI) and **[shiny.blueprint](https://github.com/Appsilon/shiny.blueprint)** (Palantir Blueprint) are built on it.
- **[posit-dev/shiny-bindings](https://github.com/posit-dev/shiny-bindings)**: (npm, py) `@posit-dev/shiny-bindings-react` and the Shiny for Python [custom components](https://shiny.posit.co/py/docs/custom-components-pkg.html) workflow it backs. Ship a custom React input/output as a Python package. See also [nstrayer/py-shiny-custom-react-component](https://github.com/nstrayer/py-shiny-custom-react-component).
diff --git a/pkg-py/docs/_quarto.yml b/pkg-py/docs/_quarto.yml
index 1238896..f27595d 100644
--- a/pkg-py/docs/_quarto.yml
+++ b/pkg-py/docs/_quarto.yml
@@ -41,6 +41,7 @@ website:
- articles/hooks.qmd
- articles/testing.qmd
- articles/agent-skills.qmd
+ - articles/comparison.qmd
- text: Python
href: py/index.qmd
- text: R
diff --git a/pkg-py/docs/articles/comparison.qmd b/pkg-py/docs/articles/comparison.qmd
new file mode 100644
index 0000000..c7bba3f
--- /dev/null
+++ b/pkg-py/docs/articles/comparison.qmd
@@ -0,0 +1,234 @@
+---
+title: "shinyreact vs. shiny.react, reactR, and friends"
+---
+
+Several R packages put React and Shiny in the same sentence, and two of them differ by one dot.
+This page says how `shinyreact` relates to each, so you can pick the right tool and stop searching for the wrong one.
+
+## The short version
+
+Every other package on this page keeps the classic Shiny model: the UI is authored in R (or Python), and React renders *some widgets inside it*.
+`shinyreact` inverts that: the UI is a React app the author owns, and Shiny is the reactive backend that feeds it JSON.
+They are complementary, not competitors.
+
+## shinyreact vs. shiny.react
+
+[shiny.react](https://appsilon.github.io/shiny.react/) by [Appsilon](https://appsilon.com/)... similar name, very different approach.
+`shiny.react` is the foundation under `shiny.fluent` and `shiny.blueprint`, and it solves the opposite problem.
+
+| | **shinyreact** | **shiny.react** |
+|---|---|---|
+| Who defines the UI | The app author, in a JS/TSX client they own | R code. `reactElement(module, name, props)` returns a `shiny.tag` |
+| Where the UI tree lives | Client. The server emits no HTML, only JSON | Server. R builds the element tree, the browser calls `React.createElement` on it |
+| Ships components? | None. It is only the bridge | None itself. It exists so wrapper packages can ship them |
+| Intended user | App authors, and AI agents writing the client | Package authors wrapping an npm component library as R functions |
+| Client to server | `useShinyInput()` / `useSetShinyInput()` hooks | `setInput()`, `triggerEvent()` props; `InputAdapter` in JS; `*.shinyInput` components |
+| Server to client | `reactive_output` publishes JSON to `useShinyOutputValue()`; `send_message()` to `useShinyMessageHandler()` | `renderReact()` / `reactOutput()` re-render an element tree; `updateReactInput()` |
+| Languages | Python and R, one shared JS bundle | R only |
+| Build tooling | Optional. A no-build `www/ui.js` works; Vite for JSX/TSX | Required. webpack + yarn, bundled into the wrapper package's `inst/www/` |
+| Mental model | Keeps Shiny's reactivity, drops "UI code mirrors UI structure in R" | Keeps the classic `ui <- ...` in R, with React widgets |
+
+Use `shiny.react` when you want an R-authored UI built from a React component library.
+Use `shinyreact` when you want to hand the whole UI to a React codebase and keep Shiny for the computation.
+
+## What travels the wire: markup or data
+
+The deepest difference between the packages on this page is the shape of a server output.
+Every other approach sends the browser a *description of UI*.
+`reactive_output` sends a *value*.
+
+Take one task: a filter input, a filtered table, and a caption with the row count.
+
+### shiny.react: the server returns an element tree
+
+```r
+library(shiny)
+library(shiny.fluent)
+
+ui <- fluentPage(
+ Dropdown.shinyInput("region", options = regions, multiSelect = TRUE),
+ reactOutput("caption"),
+ reactOutput("table")
+)
+
+server <- function(input, output, session) {
+ filtered <- reactive(sales[sales$region %in% input$region, ])
+
+ output$caption <- renderReact({
+ Text(variant = "large", sprintf("%d sales", nrow(filtered())))
+ })
+
+ output$table <- renderReact({
+ DetailsList(items = filtered(), columns = cols)
+ })
+}
+```
+
+Each `renderReact()` re-serializes a React element tree (component names, props, and their HTML dependencies) and sends it down.
+Two outputs means two payloads that both embed the same `filtered()` data, and any change to the layout is a change to R code and a redeploy.
+Client-side interactivity that the component does not already provide, such as a sort that should not round-trip, has nowhere to live.
+
+### reactR / reactable: the server returns a widget
+
+```r
+ui <- fluidPage(
+ selectInput("region", "Region", regions, multiple = TRUE),
+ textOutput("caption"),
+ reactableOutput("table")
+)
+
+server <- function(input, output, session) {
+ filtered <- reactive(sales[sales$region %in% input$region, ])
+ output$caption <- renderText(sprintf("%d sales", nrow(filtered())))
+ output$table <- renderReactable(reactable(filtered(), sortable = TRUE))
+}
+```
+
+Better: reactable owns sorting, paging, and selection on the client, and only the data crosses the wire.
+But the contract is still one widget per output, the data shape is whatever `reactable()` wants, and every other piece of UI on the page is a separate output with its own placeholder.
+The caption cannot read the table's data; it needs its own render function and its own trip through the reactive graph.
+
+### shinyreact: the server returns the data, once
+
+::: {.panel-tabset}
+
+## R
+
+```r
+server <- function(input, output, session) {
+ filtered <- reactive(sales[sales$region %in% input$region, ])
+
+ output$sales <- reactive_output({
+ df <- filtered()
+ list(n = nrow(df), rows = df)
+ })
+}
+
+shinyApp(page_react(), server)
+```
+
+## Python
+
+```python
+@reactive.calc
+def filtered():
+ return sales[sales.region.isin(input.region())]
+
+
+@reactive_output
+def sales_out():
+ df = filtered()
+ return {"n": int(len(df)), "rows": df.to_dict(orient="records")}
+```
+
+## JavaScript (`ui.tsx`)
+
+```jsx
+function SalesPanel() {
+ const [region, setRegion] = useShinyInput("region", []);
+ const sales = useShinyOutputValue("sales");
+ const status = useShinyOutputStatus("sales");
+ const [sort, setSort] = React.useState({ key: "date", dir: 1 });
+
+ if (!sales) return {sales.n} sales
+
+ {summary.n.toLocaleString()} sales, {formatCurrency(summary.total)} +
+