Observable Framework builds data apps with reactive JavaScript in Markdown. Pages are .md files in src/. JavaScript code fences execute reactively: ```js blocks run in the browser. FileAttachment("data/orders.csv").csv({ typed: true }) loads local data files. FileAttachment("data/events.parquet").parquet() reads Parquet in-browser via DuckDB WASM. Data loaders: src/data/orders.csv.js exports data — process.stdout.write(json) pipes output to the browser. process.exit(0). Python loader: src/data/stats.json.py — import json; print(json.dumps(data)). Observable Plot: Plot.plot({ marks: [Plot.line(orders, { x: "date", y: "revenue" })] }) — renders SVG. Plot.barX(by_status, { x: "count", y: "status" }). D3: fully available — d3.rollup(data, v => d3.sum(v, d => d.amount), d => d.date). Inputs.select(["7d", "30d", "90d"], { label: "Window" }) returns a reactive input. html\
Total: ${d3.format(”$,.0f”)(total)}
`renders interpolated HTML. DuckDB WASM:import { DuckDBClient } from “npm:@duckdb/duckdb-wasm”, const db = await DuckDBClient.of(), await db.query(“SELECT …”). view(input)renders an input and returns its reactive value.{visibility()}defers expensive computations until visible.observable framework buildgenerates static output.observable preview` hot-reloads locally. Claude Code generates Observable Framework pages, data loaders, Plot visualizations, and DuckDB WASM queries.
CLAUDE.md for Observable Framework
## Observable Framework Stack
- Version: @observablehq/framework >= 1.x
- Pages: src/*.md — reactive ```js blocks, FileAttachment, Plot, D3
- Data loaders: src/data/*.{json,csv,parquet}.{js,ts,py,R} — pipe data to stdout
- FileAttachment: src/data/ files — csv({typed:true}), json(), parquet()
- Plot: import * as Plot from "npm:@observablehq/plot" — Plot.plot({marks:[...]})
- DuckDB WASM: import { DuckDBClient } from "npm:@duckdb/duckdb-wasm"
- Inputs: Inputs.select/range/text/checkbox — reactive with view() and {input}
- Build: observable build → dist/; observable preview → localhost:3000
Data Loader (TypeScript)
// src/data/orders.json.ts — server-side data loader
// This file runs at build time (Node.js) — outputs JSON to stdout
import { createConnection } from "../../lib/db.js" // your DB client
async function main() {
const db = await createConnection()
const orders = await db.query<{
date: string
revenue: number
order_count: number
avg_value: number
plan: string
}>(`
SELECT
created_date::text AS date,
SUM(amount_usd) AS revenue,
COUNT(*) AS order_count,
AVG(amount_usd) AS avg_value,
user_plan AS plan
FROM marts.orders_daily
WHERE
created_date >= CURRENT_DATE - INTERVAL '90 days'
AND status = 'completed'
GROUP BY 1, 5
ORDER BY 1 DESC
`)
const kpis = await db.query<{
total_revenue: number
order_count: number
avg_order_value: number
unique_buyers: number
}>(`
SELECT
SUM(amount_usd) AS total_revenue,
COUNT(*) AS order_count,
AVG(amount_usd) AS avg_order_value,
COUNT(DISTINCT user_id) AS unique_buyers
FROM marts.orders_daily
WHERE
created_date >= CURRENT_DATE - INTERVAL '30 days'
AND status = 'completed'
`)
process.stdout.write(JSON.stringify({
orders: orders.rows,
kpis: kpis.rows[0],
updatedAt: new Date().toISOString(),
}))
process.exit(0)
}
main().catch((err) => {
process.stderr.write(err.message + "\n")
process.exit(1)
})
# src/data/cohorts.json.py — Python data loader
#!/usr/bin/env python3
import json
import os
import psycopg2
import psycopg2.extras
conn = psycopg2.connect(os.environ["DATABASE_URL"])
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""
SELECT
DATE_TRUNC('month', created_at)::DATE::text AS cohort_month,
plan,
COUNT(*) AS user_count,
AVG(lifetime_value) AS avg_ltv
FROM analytics.user_cohorts
WHERE created_at >= NOW() - INTERVAL '12 months'
GROUP BY 1, 2
ORDER BY 1, 2
""")
rows = cur.fetchall()
print(json.dumps([dict(r) for r in rows]))
Dashboard Page
<!-- src/revenue.md — Revenue dashboard using Observable Framework -->
---
title: Revenue Dashboard
toc: false
---
```js
// Load pre-computed data from loader
const data = FileAttachment("data/orders.json").json()
```
```js
// Inputs for interactivity
const windowInput = Inputs.select(["7", "30", "90"], { label: "Days", value: "30" })
const window = view(windowInput)
```
```js
// Filter data reactively based on selected window
import * as d3 from "npm:d3"
const cutoff = d3.utcDay.offset(new Date(), -parseInt(window))
const filtered = data.orders.filter(d => new Date(d.date) >= cutoff)
const kpiWindow = d3.rollup(
filtered,
v => ({
revenue: d3.sum(v, d => d.revenue),
orders: d3.sum(v, d => d.order_count),
avg_value: d3.mean(v, d => d.avg_value),
}),
() => "all"
).get("all") ?? { revenue: 0, orders: 0, avg_value: 0 }
```
# Revenue Dashboard
Last ${window} days: **${d3.format("$,.0f")(kpiWindow.revenue)}** revenue across **${d3.format(",d")(kpiWindow.orders)}** orders.
${windowInput}
## Daily Revenue
```js
import * as Plot from "npm:@observablehq/plot"
Plot.plot({
height: 300,
marks: [
Plot.areaY(filtered, Plot.binX({ y: "sum" }, {
x: d => new Date(d.date),
y: "revenue",
fill: "#6366f1",
fillOpacity: 0.2,
})),
Plot.lineY(filtered, Plot.binX({ y: "sum" }, {
x: d => new Date(d.date),
y: "revenue",
stroke: "#6366f1",
})),
],
x: { type: "utc", label: "Date" },
y: { tickFormat: d => `$${d3.format(",.0f")(d)}`, label: "Revenue" },
})
```
## Revenue by Plan
```js
const byPlan = d3.flatRollup(
filtered,
v => d3.sum(v, d => d.revenue),
d => d.plan,
)
Plot.plot({
height: 200,
marks: [
Plot.barX(byPlan, {
x: d => d[1],
y: d => d[0],
fill: "#6366f1",
sort: { y: "-x" },
tip: true,
}),
Plot.ruleX([0]),
],
x: { tickFormat: d => `$${d3.format(",.0f")(d)}`, label: "Revenue" },
y: { label: "Plan" },
})
```
In-Browser DuckDB Query
<!-- src/explore.md — ad-hoc SQL exploration with DuckDB WASM -->
```js
// Load Parquet file directly in-browser via DuckDB WASM
import { DuckDBClient } from "npm:@duckdb/duckdb-wasm"
const db = await DuckDBClient.of({
orders: FileAttachment("data/orders.parquet"),
})
```
```js
// Reactive SQL query driven by inputs
const planInput = Inputs.select(["all", "free", "pro", "enterprise"], { label: "Plan" })
const plan = view(planInput)
```
```js
const planFilter = plan === "all" ? "" : `AND plan = '${plan}'`
const result = await db.query(`
SELECT
date_trunc('week', date::DATE) AS week,
SUM(revenue) AS revenue,
SUM(order_count) AS orders
FROM orders
WHERE date >= '2025-01-01'
${planFilter}
GROUP BY 1
ORDER BY 1
`)
```
```js
Plot.plot({
marks: [Plot.barY(result, { x: "week", y: "revenue", fill: "#6366f1" })],
})
```
For the Evidence alternative when needing a SQL-focused data app framework where the primary language is SQL + Markdown without requiring JavaScript beyond component props — Evidence is simpler for data analysts who know SQL but not JavaScript while Observable Framework gives JavaScript/TypeScript engineers full control with D3, reactive cells, and custom visualizations at the cost of more code. For the Grafana alternative when needing a production-grade, ops-focused dashboard platform with time-series panels, Prometheus/Loki integration, alerting, and team-based access control — Grafana excels at infrastructure and ops monitoring while Observable Framework is better for narrative-style analytics reports, custom interactive data apps, and public data journalism. The Claude Skills 360 bundle includes Observable Framework skill sets covering reactive pages, TypeScript data loaders, Plot visualizations, and DuckDB WASM queries. Start with the free tier to try data app generation.