Arizona

Erlang CI Node.js CI Hex.pm Hex Docs npm version License

arizona logo

Arizona is a real-time web framework for Erlang/OTP. It renders HTML on the server, diffs changes at the template level, and pushes minimal updates to the browser over WebSocket.

Templates are plain Erlang terms compiled via parse transform. The server owns the state; the client is thin -- a DOM patcher in the browser, and the same diff stream drives a JSON widget tree on native app clients (see the native target below).

Arizona runs on the HTTP/WebSocket server you pick, through an adapter package: arizona_roadrunner for Roadrunner, a pure-Erlang server (beep beep), arizona_cowboy for Cowboy, or arizona_nova inside a Nova application.

🚧 Status

Arizona is in 0.x. The core is functional and covered by tests, but the API may change between minor versions. Pin an exact version in your deps (e.g. {arizona, "0.1.0"}) if you need stability across upgrades.

Features

Requirements

Installation

Add Arizona and a server adapter to your rebar.config dependencies:

{deps, [
    arizona,
    arizona_roadrunner
]}.

Arizona ships no HTTP server. The listener comes from a server adapter package: arizona_roadrunner (roadrunner: HTTP/1.1, HTTP/2, HTTP/3) or arizona_cowboy (Cowboy). An app that only renders offline (arizona_static) needs neither.

To track unreleased changes, swap the version for a git ref:

{arizona, {git, "https://github.com/arizona-framework/arizona.git", {branch, "main"}}}

The client JavaScript ships baked into the rebar3 build (priv/static/assets/js/*.min.js). If you need to bundle it yourself or consume it from a non-Erlang backend, install via npm:

npm install @arizona-framework/client
import { connect } from '@arizona-framework/client';

connect('/ws');

connect spawns its WebSocket worker via a static new Worker(new URL('./arizona-worker.min.js', import.meta.url), { type: 'module' }), so a downstream Vite/Rollup/rolldown build detects it and emits (and content-hashes) the worker automatically, with no runtime path to configure. Apps that never call connect tree-shake the worker out entirely. Loading arizona.min.js standalone via <script type="module"> still works: the worker resolves as its sibling next to the client.

Quick start

A page with an embedded counter.

1. The counter component

id and initial count come from the parent via ?stateful (step 2), so mount/1 just passes them through:

%% src/my_counter.erl
-module(my_counter).
-include_lib("arizona/include/arizona_stateful.hrl").
-export([mount/1, render/1, handle_event/3]).

mount(Bindings) ->
    {Bindings, #{}}.

render(Bindings) ->
    ?html(
        %% id must be on the root element -- if it changes, the component is remounted
        {'div', [{id, ?get(id)}], [
            {button, [{az_click, arizona_js:push_event(~"dec")}], [~"-"]},
            {span, [], [~" Count: ", ?get(count), ~" "]},
            {button, [{az_click, arizona_js:push_event(~"inc")}], [~"+"]}
        ]}
    ).

handle_event(~"inc", _Payload, Bindings) ->
    {Bindings#{count => maps:get(count, Bindings) + 1}, #{}, []};
handle_event(~"dec", _Payload, Bindings) ->
    {Bindings#{count => maps:get(count, Bindings) - 1}, #{}, []}.

?get(count) registers count as a dependency of that template slot. When handle_event returns new bindings, only slots whose tracked keys changed re-render -- the <span> patches; the buttons don't.

The tuples carry more than just bindings: mount/1 returns {Bindings, Resets} (an explicit slot-reset map -- usually #{}), and handle_event/3 returns {Bindings, Resets, Effects} where Effects is a list of arizona_js commands (set_title, navigate, …) executed on the client.

2. The parent page

A route's root handler is a stateful handler. It receives initial bindings:

%% src/my_page.erl
-module(my_page).
-include_lib("arizona/include/arizona_stateful.hrl").
-export([mount/1, render/1]).

mount(Bindings) ->
    {Bindings, #{}}.

render(Bindings) ->
    ?html(
        {main, [{id, ?get(id)}], [
            {h1, [], [~"Counter demo"]},
            %% id is required -- it's how the diff engine routes patches to this component
            ?stateful(my_counter, #{id => ~"counter", count => 0})
        ]}
    ).

3. The layout

The HTML shell. Loads the client runtime that connects over WebSocket:

%% src/my_layout.erl
-module(my_layout).
-include_lib("arizona/include/arizona_stateless.hrl").
-export([render/1]).

render(Bindings) ->
    ?html([
        ~"<!DOCTYPE html>",
        {html, [], [
            {head, [], [
                {meta, [{charset, ~"utf-8"}]},
                {title, [], [?get(title, ~"Arizona")]}
            ]},
            {body, [], [
                ?inner_content,
                {script, [{type, ~"module"}], [
                    ~"""
                    import { connect } from '/assets/arizona.min.js';
                    connect('/ws');
                    """
                ]}
            ]}
        ]}
    ]).

4. Configure the server

Add arizona and the adapter to your app's applications list in .app.src:

{applications, [kernel, stdlib, arizona, arizona_roadrunner]}

Arizona lists no server itself, so an app that renders pages offline only (arizona_static) never boots one. Add ssh too if you serve the SSH transport.

Ensure rebar3 shell loads the config and starts your app by adding to rebar.config:

{shell, [
    {config, "config/sys.config"},
    {apps, [yourapp]}
]}.

Replace yourapp with your project's app name. Then declare routes in config/sys.config:

[{arizona, [
    {server, #{
        adapter => arizona_roadrunner_server,
        routes => [
            {live, ~"/", my_page, #{
                layouts => [{my_layout, render}],
                bindings => #{id => ~"page", title => ~"Counter demo"}
            }},
            {ws, ~"/ws", #{}},
            {asset, ~"/assets", {priv_dir, arizona, "static/assets/js"}}
        ]
    }}
]}].

The server map's required adapter key names the arizona_server module of the adapter package (arizona_roadrunner_server, arizona_cowboy_server); every other key is that adapter's own options.

A config file is read by file:consult/1, so every route entry must be a literal term. That becomes visible once a route needs middlewares: a {Module, Function} pair is a term, but a call such as arizona_middleware:extract([path_bindings]) is not, and the node refuses to boot with bad term -- reported at line 1 whatever line the expression is on, since the whole file is one term. Wrap a parameterised step in a named 2-arity function, or declare the routes in Erlang instead; see Declaring steps in arizona_middleware.

rebar3 shell reloads dependency modules on recompile. If that logs a SUPERVISOR REPORT for the listener every time you save, add {app_reload_blacklist, [roadrunner]} to the shell block: reloading a module purges its old code, which kills any process still lingering in it -- a listener sits blocked in its own loop, so it is exactly that. Arizona's own reloader reads the same option.

5. Run it

rebar3 shell

Open http://localhost:4040 and click the buttons -- the server renders the initial HTML, then pushes minimal diffs over WebSocket as the count changes.

Documentation

See docs/architecture.md for the full architecture reference -- module breakdown, op codes, dev-mode file watchers, custom schemes/proto_opts, and imperative startup.

See docs/native.md for the native (JSON widget-tree) render target -- authoring ?native views, serving HTML and native from one view, the client wire contract, and the in-repo Android client.

Sponsors

Arizona is open source and maintained on personal time. If you or your company find it useful, consider sponsoring.

I also accept coffees ☕

"Buy Me A Coffee"

Sponsors

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for development setup, testing guidelines, and contribution workflow.

Contributors

Contributors

Star History

Star History Chart

License

Copyright (c) 2023-2026 William Fank Thomé

Arizona is open-source under the Apache 2.0 License on GitHub.

See LICENSE.md for more information.