Frontend development

araara is a backend. Each route returns whatever your project needs — server-rendered HTML, JSON, or both — and nothing is mandatory. For server-rendered UIs, araara suggests a small kit of htmx, TyXML and ocsigen-i18n that produces typed HTML and updates it in the browser with no build step. Prefer to compile OCaml to JavaScript, or point a JS framework at a JSON API? That works too. This guide walks through the kit, then the alternatives.

The frontend is yours

An araara application is a server that speaks HTTP. What a given route returns is entirely your decision: some routes render HTML for a browser, some return JSON for a programmatic client, some do both, and many applications need only one. Nothing here is mandatory or implicit — araara does not generate an API behind your back, and it never requires one.

This documentation site is the obvious example: it serves only HTML. It has no JSON API because nothing consumes one, and adding it would be dead weight. A headless service might be the reverse — only JSON, no HTML — and a typical app mixes the two. You expose exactly the surfaces your project needs, and no more.

When a single resource does grow both an HTML page and a JSON endpoint, the convention is to back them with the same context so the two cannot drift — web/API parity. That is a convention for when you have both, not a rule that you must.

For the UI itself, araara proposes hypermedia — HTML rendered on the server and swapped into the page with htmx — as the default, and the examples here use it. It is a proposal, not a requirement; the last section covers compiling OCaml to JavaScript or using a JS/TS framework instead.

Why hypermedia is the default: there is exactly one rendering of your domain — the server's — so there is no client-side store to keep in sync, no JSON schema duplicating your types, and no build step in front of your UI. For a large class of applications that is less code and fewer moving parts.

A frontend kit: htmx, TyXML, ocsigen-i18n

When you do render the UI on the server, araara leans on three community libraries that fit together into a small, build-step-free frontend kit. None is part of araara — they are conventions it adopts — and each does one job.

TyXML renders the HTML. Views are ordinary OCaml functions from data to a typed node tree: escaped by default, with htmx attributes and translation lookups written inline as plain function calls.

htmx makes that HTML interactive. An attribute on an element says which URL to call and where to put the HTML that comes back, so the page updates in place — without JavaScript of your own and without client-side state.

ocsigen-i18n supplies the words. Translations live in TSV files and compile to typed lookups, so a multilingual UI cannot ship a missing or misspelled key.

Together they form one pipeline: TyXML builds the markup with htmx attributes and i18n calls baked in, the server sends it, htmx swaps it into the page, and ocsigen-i18n has already filled in the right language. The result is a typed, translatable, server-rendered UI with no bundler and — for a site like this one — no hand-written JavaScript. The sections that follow take each piece in turn.

The default: HTML over the wire

Three tools each handle one job. htmx turns any element into a hypermedia control: an attribute says which URL to hit and where to put the HTML that comes back. Server-Sent Events push fragments when state changes on the server. alpinejs handles state that never needs the server — an open menu, a focused tab. The server owns everything else.

One view function, every transport

examples/htmx_fragments/main.ml
(* One view function renders a todo row. The full page and the htmx
   fragment response both use it — that is the whole trick. *)
let todo_row text_ =
  li ~a:[ a_class [ "todo" ] ] [ txt text_ ]

let todo_list todos =
  ul ~a:[ a_id "todos"; a_class [ "stack" ] ] (List.map todo_row todos)

todo_row renders one item. The full page calls it through todo_list, the POST handler returns it alone, and an SSE push would stream it. Because all three paths share one function, there is no second copy that can drift out of sync.

The form declares the swap

examples/htmx_fragments/main.ml
(* The form posts via htmx and appends the returned <li>
   fragment to #todos. No page reload, no JavaScript written. *)
form
  ~a:[
    hx "hx-post" "/todos";
    hx "hx-target" "#todos";
    hx "hx-swap" "beforeend";
    hx "hx-on::after-request" "this.reset()";
  ]
  [
    label ~a:[ a_label_for "text" ] [ txt "New todo" ];
    input ~a:[ a_id "text"; a_name "text"; a_required () ] ();
    button ~a:[ a_button_type `Submit ] [ txt "Add" ];
  ];

Read the attributes: Hx.post submits over XHR; Hx.target names the element to update; Hx.swap "beforeend" appends the response inside it. hx-on::after-request resets the form when the round trip finishes.

The handler returns a fragment

examples/htmx_fragments/main.ml
let create _params req =
  match form_value (Hcs.Request.body req) "text" with
  | None | Some "" -> Hcs.Server.respond ~status:`Bad_request "text required"
  | Some text_ ->
      let rec add () =
        let old = Atomic.get todos in
        if not (Atomic.compare_and_set todos old (text_ :: old)) then add ()
      in
      add ();
      (* htmx asked for a fragment: return only the new row. *)
      Hcs.Server.respond_html (Format.asprintf "%a" (Tyxml.Html.pp_elt ()) (todo_row text_))
in

Validate, update state, respond with just the new

  • . htmx drops it into the list. The handler did not serialize to JSON, invent an API shape, or coordinate a client-side store. The HTML it returns is the response the browser acts on, so there is no separate JSON contract to build or keep in sync.

    $ dune exec examples/htmx_fragments/main.exe
    $ xdg-open http://localhost:8080

    Rendering HTML in OCaml

    The examples render HTML with TyXML, a community library (not part of araara) that expresses HTML as a typed OCaml DSL: elements are constructors, text is escaped by default, and htmx attributes are ordinary HTML attributes. No template language, no separate compile step — a view is a function from data to a node tree.

    examples/tyxml_components/main.ml
    type product = { name : string; price_cents : int; in_stock : bool }
    
    (* A component: data in, node out. Test it by calling it. *)
    let product_card (prod : product) =
      article
        ~a:[ a_class [ "card" ] ]
        [
          header [ h3 [ txt prod.name ] ];
          p [ txt (Printf.sprintf "$%d.%02d" (prod.price_cents / 100) (prod.price_cents mod 100)) ];
          (if prod.in_stock then
             button ~a:[ a_class [ "btn"; "btn--primary" ] ] [ txt "Add to cart" ]
           else span ~a:[ a_class [ "pill" ] ] [ txt "Out of stock" ]);
        ]

    Because a component is just a function, it refactors like one and a unit test calls it and asserts on the rendered string. TyXML is a convention araara adopts, not a dependency it owns — use any server-side HTML approach you prefer; htmx only cares that the response is HTML.

    Translations: ocsigen-i18n

    For multilingual server-rendered UIs, ocsigen-i18n — another community library — turns TSV files into typed lookup functions, so a missing key is a compile error. Translations live next to the views that use them.

    examples/i18n_hello/main.ml
    let print_in lang =
      (* Every [%i18n ...] lookup below now resolves in [lang]. *)
      Translations.set_language lang;
      Printf.printf "%s:\n" (Translations.string_of_language lang);
      Printf.printf "  %s\n" [%i18n greeting];
      Printf.printf "  %s\n" [%i18n farewell]
    
    let () =
      (* [Translations.languages] lists the TSV columns in order: En; Es. *)
      List.iter print_in Translations.languages;

    Fragment or full page? Let the header decide

    Every htmx request carries HX-Request: true. The araara convention is one controller action that renders both: if the request is from htmx, return the fragment; on direct navigation or refresh, return the same fragment wrapped in the full layout. A small helper — Render.html_partial req ~partial ~full — makes the branch one line, and every URL stays bookmarkable and refreshable.

    The one subtlety is hx-boost: boosted links are full navigations dressed as XHR, and they expect the layout — carve them out by checking HX-Boosted.

    Live updates and local state

    For server-pushed updates the page declares a connection and a swap target with the htmx sse extension — Hx.ext "sse", Hx.sse_connect on a container, Hx.sse_swap on the element that receives events. The server responds with Hcs.Sse.respond, emitting each update as an HTML fragment — usually the same row function the POST handler uses, fed by hive's pubsub after a context commits a write. The hcs documentation has a runnable SSE server you can curl.

    The examples/helpdesk app, open as two users at once. When the customer (right) opens a ticket, it appears in the operator's queue (left) instantly — pushed over Server-Sent Events, exactly as described here. No polling, no refresh.

    alpinejs covers state that would be silly to round-trip: open/closed, hover, a character counter. Attach x-data to the semantic element, not a wrapper div. A simple rule of thumb keeps the two apart: any state that must survive a page reload belongs on the server, reached through htmx. Use alpinejs only for throwaway UI state, like whether a menu is open.

    A few conventions we follow: vendor htmx and alpine into priv/static/vendor/ and pin versions (the example uses a CDN only to stay single-file); keep one hand-authored stylesheet with semantic class names; prefer semantic elements over divs; and serve a strict Content-Security-Policy that pins exactly the scripts the site ships by hash, with syntax highlighting done on the server rather than in the browser.

    Other frontends

    When an application genuinely needs a rich client — a canvas editor, an offline-first app, a design that is unapologetically a single-page application — araara does not stand in the way. Expose a JSON API for it and that API is a first-class surface, backed by the same contexts as any HTML routes, so whatever speaks HTTP is a supported client.

    Other server-side HTML renderers

    TyXML is the araara convention because it is typed, mature, and already fits the OCaml web ecosystem. It is not a hard dependency of hcs or of the layering rules: htmx only requires that your controller returns HTML. If a project prefers a different server-side HTML DSL, pure-html is one reasonable alternative; templates or hand-rolled renderers can also work when they keep escaping and composition disciplined.

    OCaml in the browser

    Stay in OCaml end to end by compiling to JavaScript. Brr gives direct, typed bindings to the DOM and browser APIs; js_of_ocaml compiles existing OCaml to JS; Melange compiles OCaml to readable JS and integrates with the npm and React ecosystems. Each talks to araara's JSON API — decode responses with the same kinds of codecs simdjsont uses on the server.

    For a rich client with genuine live state, brote is the reactive option built for this: incremental signals with cutoff and keyed-stable collections on a stdlib-only core that compiles to native and to JavaScript, with adapters that serve live collections over an hcs WebSocket and replicate them across a hive cluster. The reactive graph runs in the browser and the wire carries state deltas, not markup. It is a peer to the hypermedia kit, not a replacement — render most pages on the server and reach for a brote client only on the screens that need one. See the brote reference and the trading-floor tutorial that builds a complete brote application end to end.

    ReasonML, ReScript, and JS/TS frameworks

    ReasonML and ReScript are ML-family languages that compile to JavaScript and pair naturally with React. And nothing requires the frontend to be ML at all: React, Vue, Svelte, SolidJS or a plain fetch() call work against the JSON API exactly as they would against any HTTP backend. araara's job is to be a correct, fast server; the rendering technology is your decision.