Frontend development

araara is a backend: it serves HTTP, and what each route returns is your choice — server-rendered HTML, JSON, both, or whatever a given project needs. Nothing is mandatory or implicit. For server-rendered UIs, araara proposes a small kit — htmx, pure-html and ocsigen-i18n — that produces typed HTML and updates it in the browser without a build step; if you would rather compile OCaml to JavaScript or point a JS/TS framework at a JSON API, that works too. This guide introduces the kit, then covers each piece, 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, pure-html, 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.

pure-html 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: pure-html 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, three jobs. 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 server-side. 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 [ class_ "todo" ] [ txt "%s" text_ ]

let todo_list todos =
  ul [ id "todos"; 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; an SSE push would stream it. One function, three transports — nothing to drift.

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
  [
    Hx.post "/todos";
    Hx.target "#todos";
    Hx.swap "beforeend";
    string_attr "hx-on::after-request" "this.reset()";
  ]
  [
    label [ for_ "text" ] [ txt "New todo" ];
    input [ id "text"; name "text"; required ];
    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 (to_string (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 hypermedia is the API.

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

    Rendering HTML in OCaml

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

    examples/pure_html_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
        [ class_ "card" ]
        [
          header [] [ h3 [] [ txt "%s" prod.name ] ];
          p []
            [ txt "$%d.%02d" (prod.price_cents / 100) (prod.price_cents mod 100) ];
          (if prod.in_stock then
             button [ class_ "btn btn--primary" ] [ txt "Add to cart" ]
           else span [ 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. pure-html 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. The line: anything that should survive a reload goes through htmx to the server. If application state ends up in x-data, it has crossed it.

    House rules: vendor htmx and alpine into priv/static/vendor/ and pin versions (the example uses a CDN only to stay single-file); one hand-authored stylesheet with semantic class names; semantic elements over divs; and a strict Content-Security-Policy that pins exactly the scripts the site ships by hash, with syntax highlighting done server-side 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.

    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.

    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.