An OCaml framework for building reactive web applications with server-side rendering (SSR), inspired by SolidJS.
Status: Discontinued. The experiment is winding down — the framework kind of works (tests pass, SSR and hydration operate), but there are likely bugs still lurking, and practical application development proved too high-friction to justify continued investment. The repository remains public as a reference. See the blog posts for the full story.
solid-ml was an experiment in using AI agents to port the architectural philosophy of SolidJS — fine-grained reactivity, no Virtual DOM, high-performance hydration — to OCaml. Development followed a human-AI partnership structure, with a human "Governor" managing architecture while LLMs (Claude and GPT) handled code generation over an 18-day sprint.
What worked well: the MLX template language feels natural if you've used JSX, and the compiler catches mismatched tags. The core reactivity engine is solid.
What didn't: targeting full SSR with hydration introduced substantial complexity (hydration markers, isomorphic module interfaces, template compilation requiring identical output across environments) that proved difficult to maintain and debug in an AI-generated codebase.
open Solid_ml_server
let () =
Runtime.run (fun () ->
let dispose =
Owner.create_root (fun () ->
(* Create a signal (reactive value) *)
let count, set_count = Signal.create 0 in
(* Create a memo (derived value) *)
let doubled = Memo.create (fun () ->
Signal.get count * 2
) in
(* Create an effect (side effect that re-runs when dependencies change) *)
Effect.create (fun () ->
Printf.printf "Count: %d, Doubled: %d\n"
(Signal.get count)
(Memo.get doubled)
);
(* Update the signal - effect automatically re-runs *)
set_count 1; (* prints: Count:1, Doubled: 2 *)
set_count 2 (* prints: Count:2, Doubled: 4 *)
)
in
dispose ()
)Add one of the following to your project's dune-project:
; Full stack + MLX (umbrella)
(depends
(solid-ml (>= 0.1.0))); Server-only
(depends
(solid-ml-server (>= 0.1.0))
(solid-ml-ssr (>= 0.1.0))); Browser-only
(depends
(solid-ml-browser (>= 0.1.0))); Routing (SSR-aware)
(depends
(solid-ml-router (>= 0.1.0)))Reactive primitives bind to the current owner/runtime if present. On the server,
solid-ml-server does not create an implicit runtime: you must call Runtime.run per
request (or use SSR helpers that do it for you) to keep state isolated.
(* Create isolated reactive context (recommended for SSR per-request) *)
Runtime.run (fun () ->
let _count, _set_count = Signal.create 0 in
()
)SSR helpers like Solid_ml_ssr.Render.to_string create and dispose a runtime internally.
(* Create a signal with initial value (uses structural equality by default) *)
let count, set_count = Signal.create 0
(* Create with physical equality (for mutable values) *)
let buffer, set_buffer = Signal.create_physical (Bytes.create 100)
(* Create with custom equality *)
let items, set_items = Signal.create_eq
~equals:(fun a b -> List.length a = List.length b)
[]
(* Read value (tracks dependency in effects/memos) *)
let value = Signal.get count
(* Read without tracking *)
let value = Signal.peek count
(* Update value - only notifies if value changed *)
Signal.set count 42
Signal.update count (fun n -> n + 1)(* Effect re-runs when any signal it reads changes *)
Effect.create (fun () ->
print_endline (string_of_int (Signal.get count))
)
(* Effect with cleanup *)
Effect.create_with_cleanup (fun () ->
let subscription = subscribe_something () in
fun () -> unsubscribe subscription
)
(* Read signal without tracking *)
let value = Effect.untrack (fun () -> Signal.get some_signal)(* Memo caches derived value, only recomputes when deps change *)
let doubled = Memo.create (fun () ->
Signal.get count * 2
)
(* Read memo like a signal *)
let value = Memo.get doubled(* Batch multiple updates, effects run once at end *)
Batch.run (fun () ->
Signal.set first_name "John";
Signal.set last_name "Doe"
)(* Create a root that owns effects - dispose cleans everything up *)
let dispose = Owner.create_root (fun () ->
Effect.create (fun () -> ...)
) in
dispose () (* All effects inside are disposed *)
(* Register cleanup with current owner *)
Owner.on_cleanup (fun () ->
print_endline "Cleaning up!"
)(* Create a context with default value *)
let theme_context = Context.create "light"
(* Provide value to descendants *)
Context.provide theme_context "dark" (fun () ->
(* Code here sees "dark" *)
let theme = Context.use theme_context in
...
)
)Router lives in solid-ml-router with SSR-aware components and loaders. See examples/README.md for runnable demos.
# Build all packages
dune build
# Run tests
dune runtest- OCaml 5.0+ (uses Domain-local storage for thread safety)
- dune 3.16+ with Melange support (
(using melange 0.1)) - For browser builds: Node.js (for esbuild bundling)
- For web server examples: Dream (not included - see examples for reference code)
We currently optimize MLX DX around ergonomic (...) interpolation and avoid
adopting {...} syntax in this repository.
Similarities:
- Signals/effects/memos with automatic dependency tracking
- Batch updates via
Batch.run - Suspense boundaries and error boundaries
Differences:
- Effects run synchronously by default (browser can opt into microtask deferral)
- Memos use structural equality by default (override with
~equals) - No concurrent rendering or transitions
- solid-ml-browser can create an implicit runtime; solid-ml-server requires
Runtime.runper request. SolidJS SSR creates a new root per render/request.
Practical guidance:
- For server code, always create a runtime per request using
Runtime.run, or use SSR helpers likeSolid_ml_ssr.Render.to_string/to_documentwhich create and dispose a runtime for you. - Avoid creating signals at top level in server code; they now raise because no runtime is active.
- solid-ml: The Experiment of Making AI Agents Port SolidJS to OCaml - Origin story, development methodology, and technical details
- The State of solid-ml - What worked, what didn't, and why the experiment is winding down
- CHANGELOG.md - Release notes and breaking changes
- LIMITATIONS.md - Concise list of real constraints
- docs/guide-mlx.md - MLX syntax and template PPX setup
- docs/guide-mlx-migration.md - Mechanical rewrites for reducing Tpl ceremony
- docs/rfc-mlx-dx.md - Proposed DX improvements for MLX surface
- docs/rfc-mlx-implementation-plan.md - Staged implementation plan for MLX DX RFC
- docs/guide-ssr-hydration.md - SSR, hydration, and state transfer
- examples/README.md - Example index and build commands