An OCaml framework for building reactive web applications with server-side rendering (SSR), inspired by SolidJS.
Status: All phases complete! ✅ Reactive core, SSR, client runtime, router, and Suspense/ErrorBoundary are ready. 124 tests passing.
Started: January 5, 2026 (2 days of intensive development)
Maturity: Experimental - Not battle-tested in production yet.
solid-ml is a mostly faithful port of SolidJS to OCaml, enabling full-stack OCaml web applications where validation logic, types, and business logic can be seamlessly shared between server and client. While all core features are implemented with comprehensive test coverage (208 tests), solid-ml has not been used in production and may have undiscovered edge cases.
Expect rapid iteration, breaking changes, and active development. Use at your own risk. If you're adventurous and want to help stabilize it, contributions and feedback are welcome!
- Fine-grained reactivity - Signals, effects, and memos with automatic dependency tracking
- Server-side rendering - Full HTML rendering for SEO and fast first paint
- Client-side hydration - Seamless hydration and reactive DOM updates via Melange
- SSR-aware routing - Router with data loaders, navigation, and active link tracking
- Suspense & Error Boundaries - Async loading states and error handling
- Thread-safe - Domain-local storage enables safe parallel execution (OCaml 5)
- Type-safe - Full OCaml type checking for your UI
- SolidJS-compatible - Familiar API for SolidJS developers
open Solid_ml
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)
(Signal.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 ()
)All reactive code must run within a Runtime.run context:
(* Create isolated reactive context *)
Runtime.run (fun () ->
(* Reactive code here *)
)
(* For Dream/web servers - each request gets its own runtime *)
let handler _req =
let html = Solid_ml_ssr.Render.to_string my_component in
Dream.html html(* 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 = Signal.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
...
)(* Suspense boundary for async loading states *)
let ui = Suspense.boundary
~fallback:(fun () -> [Html.div [] [Html.text "Loading..."]])
~children:(fun () ->
let data = Resource.read_suspense my_resource ~default:[] in
[Html.div [] (List.map render_item data)]
)
(* Error boundary for catching errors *)
let ui = ErrorBoundary.make
~fallback:(fun error reset ->
[Html.div [] [
Html.text ("Error: " ^ error);
Html.button [Html.on_click (fun _ -> reset ())]
[Html.text "Retry"]
]]
)
~children:(fun () ->
(* Code that might throw *)
[Html.div [] [Html.text "Success!"]]
)open Solid_ml_router
(* Define routes *)
let routes = [
Route.make "/" (fun _ -> home_page ());
Route.make "/users/:id" (fun params ->
let id = List.assoc "id" params in
user_page id
);
]
(* Server-side: render with initial URL *)
let html = Router.render_to_string routes "/users/123"
(* Browser-side: hydrate with client-side navigation *)
let () = Router.hydrate routes (Dom.get_element_by_id "app")solid-ml uses OCaml 5's Domain-local storage for thread safety:
- Each domain has independent runtime state
- Safe for parallel execution with
Domain.spawn - Each Dream request can run in its own runtime
(* Parallel rendering across domains *)
let results = Array.init 4 (fun _ ->
Domain.spawn (fun () ->
Runtime.run (fun () ->
Render.to_string my_component
)
)
) |> Array.map Domain.joinImportant: Signals should not be shared across runtimes or domains. Each runtime maintains its own reactive graph.
| Package | Description | Status |
|---|---|---|
solid-ml-internal |
Shared functor-based reactive core | ✅ Complete |
solid-ml |
Server-side reactive framework (OCaml 5 + DLS) | ✅ Complete |
solid-ml-ssr |
Server-side rendering to HTML strings | ✅ Complete |
solid-ml-browser |
Client-side rendering and hydration (Melange) | ✅ Complete |
solid-ml-router |
SSR-aware routing with data loaders | ✅ Complete |
Note: solid-ml-browser requires Melange 3.0+ for building client-side code.
# Counter - reactive primitives demo
dune exec examples/counter/counter.exe
# Todo list - list operations and SSR
dune exec examples/todo/todo.exe
# Router - routing with params and navigation
dune exec examples/router/router.exe
# Parallel rendering - OCaml 5 domain safety
dune exec examples/parallel/parallel.exeThe SSR server examples require Dream and are disabled by default to keep the core library dependencies minimal.
# SSR server with routing (requires dream)
dune exec examples/ssr_server/server.exe
# Full SSR app with hydration (requires dream)
make example-full-ssr
# SSR API demo (requires dream)
make example-ssr-api# Build browser examples
make browser-examples
# Serve and open http://localhost:8000
make serveSee also:
examples/browser_counter/- Browser counter with client-side reactivityexamples/browser_router/- Browser router with client-side navigationexamples/js_framework_benchmark/- JS Framework Benchmark
## Building
```bash
# 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)
Add to your dune-project:
(package
(name my-app)
(depends
(solid-ml (>= 0.1.0))
(solid-ml-ssr (>= 0.1.0))
(solid-ml-router (>= 0.1.0)))) ; Optional
(source
(github makerprism/solid-ml))Then run:
dune pkg lock
dune buildopam pin add solid-ml.0.1.0 git+https://github.com/makerprism/solid-ml#main
opam pin add solid-ml-ssr.0.1.0 git+https://github.com/makerprism/solid-ml#main
opam pin add solid-ml-router.0.1.0 git+https://github.com/makerprism/solid-ml#mainopam install solid-ml solid-ml-ssr solid-ml-routersolid-ml uses a functor-based architecture to share reactive algorithms between server and browser:
solid-ml-internal: Core reactive functor (platform-agnostic)solid-ml: Server instantiation with Domain-local storage (thread-safe)solid-ml-browser: Browser instantiation with global ref (single-threaded JS)
This design allows the same reactive code to run on both server (for SSR) and client (for hydration).
- Fine-grained updates: No virtual DOM diffing - signals update only their subscribers
- Automatic dependency tracking: Reading a signal inside an effect/memo auto-subscribes
- Eager memos: Like SolidJS, memos compute immediately (not lazy)
- Type safety via phantom types: Internal
Obj.tfor heterogeneous collections, safe typed API - SSR-first: Components render to HTML strings, client hydrates existing DOM
- Signals cannot be shared across runtimes/domains (by design for thread safety)
- Always dispose
Owner.create_rootto prevent memory leaks Render.to_stringhandles disposal automatically- No async in effects - fetch data before entering reactive context (use Resources in router)
For detailed limitations and workarounds, see LIMITATIONS.md.
solid-ml has comprehensive test coverage:
- 31 tests - Reactive core (signals, effects, memos, batching)
- 23 tests - HTML rendering and SSR
- 36 tests - SolidJS compatibility
- 91 tests - Router (matching, navigation, data loading)
- 14 tests - Browser reactive core
- 13 tests - Suspense and ErrorBoundary
Total: 208 tests across all packages.
Run tests with:
dune runtest # Native OCaml tests
make browser-tests # Browser tests via Node.js| Phase | Status | Description |
|---|---|---|
| Phase 1: Reactive Core | ✅ Complete | Signals, effects, memos, batching, ownership, context |
| Phase 2: Server Rendering | ✅ Complete | HTML generation, SSR, hydration markers |
| Phase 3: Client Runtime | ✅ Complete | DOM bindings, hydration, reactive updates via Melange |
| Phase 4: Router | ✅ Complete | Route matching, navigation, data loaders, SSR support |
| Phase 5: Suspense | ✅ Complete | Async boundaries, error handling, unique IDs |
All core features are implemented and tested. Ready for experimental use - waiting for real-world validation.
- AGENTS.md - Development guidelines and project structure
- docs/A-01-architecture.md - Full architecture document
- LIMITATIONS.md - Known limitations and workarounds
Contributions welcome! See AGENTS.md for development guidelines.
MIT