Mob runs on iOS and Android. Pick your target — you don't need both.

iOS onlyAndroid onlyBoth platformsLiveView projects


iOS only

What you need

  • macOS
  • Xcode 15 or later (xcode-select --install for command-line tools)
  • Elixir 1.19 or later with Hex: mix local.hex
  • mob_new installed: mix archive.install hex mob_new

That's enough to run on the iOS Simulator. For a physical iPhone, you also need:

  • An Apple ID — free at https://appleid.apple.com
  • Xcode signed in with that Apple ID: open Xcode → Settings → Accounts → [+]
  • (For App Store distribution only) Apple Developer Program — $99/year. Free accounts can deploy to your own devices; profiles expire every 7 days.

Create a project

mix mob.new my_app --ios
cd my_app
mix mob.install

--ios scopes the generator to iOS only — no android/ directory is created and mix mob.install skips the Android OTP download (saves ~400 MB of cache). Drop the flag if you want both platforms.

mix mob.install downloads the pre-built OTP runtime for iOS and writes your mob.exs.

Verify your environment

mix mob.doctor

Fix any failures before continuing. See Troubleshooting if needed.

Run on the iOS Simulator

Boot a simulator from Xcode → Open Simulator, or:

xcrun simctl boot "iPhone 16 Pro"
open -a Simulator

Then deploy:

mix mob.deploy --native --ios

This builds the .app bundle, installs it in the simulator, and pushes your BEAM files. Subsequent deploys without --native are faster — they push only changed .beam files:

mix mob.deploy --ios

Run on a physical iPhone

There are three one-time steps before your first deploy. Do them in order.

Step 1 — Trust the Mac

Connect your iPhone to your Mac with a USB cable. The phone will show:

"Trust This Computer?"

Tap Trust, then enter your passcode. If this dialog doesn't appear, unplug and replug the cable, or try a different port.

Step 2 — Enable Developer Mode on the iPhone

This is required on iOS 16 and later. You only do it once per phone.

On the iPhone: Settings → Privacy & Security → Developer Mode → turn ON

The phone will warn you and ask to restart. Tap Restart. After it reboots, a dialog appears asking to confirm — tap Enable and enter your passcode.

If you don't see Developer Mode in Settings, make sure the phone is connected to the Mac and Xcode is open. Xcode needs to recognise the device at least once for the option to appear.

Step 3 — Register your app ID and get a provisioning profile

Apple requires every app installed on a physical device to be signed with a provisioning profile tied to your developer account. Run:

mix mob.provision

This generates a small Xcode project stub, uses it to register your bundle ID with Apple, and downloads a development provisioning profile — all from the command line. You won't need to build or launch anything in Xcode.

If mix mob.provision fails: open Xcode, select your phone from the device picker at the top, and wait for it to finish "Preparing device for development". Then re-run mix mob.provision.

Deploy

mix mob.deploy --native --ios

Mob auto-detects the connected phone. The app will appear on your home screen.

If the profile expires (free accounts: every 7 days, paid Developer Program: 1 year), re-run mix mob.provision then deploy again.


Android only

What you need

  • Elixir 1.19 or later with Hex: mix local.hex
  • mob_new installed: mix archive.install hex mob_new
  • Java 17–21 (brew install --cask temurin on macOS)
  • Android Studio (includes the SDK and adb)

For a physical Android device: enable Developer Options and USB Debugging on the device, then connect via USB and accept the authorization prompt.

Create a project

mix mob.new my_app --android
cd my_app
mix mob.install

--android scopes the generator to Android only — no ios/ directory is created and mix mob.install skips the iOS OTP download. Drop the flag if you want both platforms.

mix mob.install downloads the pre-built OTP runtime for Android and writes your mob.exs and android/local.properties.

Verify your environment

mix mob.doctor

Fix any failures before continuing. See Troubleshooting if needed.

Run on an emulator

Start an AVD from Android Studio → Device Manager, then:

mix mob.deploy --native --android

This builds the APK, installs it on the emulator, and pushes your BEAM files. Subsequent deploys without --native push only changed .beam files:

mix mob.deploy --android

Run on a physical Android device

There are two one-time steps before your first deploy.

Step 1 — Enable Developer Mode on the phone

Android hides developer options until you unlock them.

  1. Open Settings → About phone
    • On Samsung: Settings → About phone → Software information
  2. Find Build number and tap it 7 times in quick succession
  3. You'll see: "You are now a developer!"
  4. Go back to Settings — a new Developer options entry has appeared
  5. Open Developer options and turn on USB debugging

Step 2 — Connect via USB and set the mode

Plug in the USB cable. On the phone:

  • A prompt appears: "Allow USB debugging?" — tap Allow (tick "Always allow from this computer" so you're not asked every time)
  • Pull down the notification shade and tap the USB connection notification
  • Select File Transfer (sometimes labelled "MTP") — not "Charging only"

Verify the connection:

adb devices

You should see your device listed as device (not unauthorized or offline). If it shows unauthorized, check for a missed dialog on the phone screen.

Deploy

mix mob.deploy --native --android

Mob detects connected devices automatically. If you have more than one, use mix mob.devices to find the serial ID and --device <id> to target it.


Both platforms

What you need

Everything from the iOS and Android sections above — but you don't need a physical device for both. Mix and match whatever you actually have:

SetupWhat to do
iOS Simulator + Android emulatorNothing extra — just deploy
Physical iPhone + Android emulatorSet up iPhone (trust + Developer Mode + mix mob.provision)
iOS Simulator + physical AndroidSet up Android (Developer Mode + USB debugging + File Transfer)
Physical iPhone + physical AndroidSet up both phones, then mix mob.provision for iOS

Create a project

mix mob.new my_app
cd my_app
mix mob.install

Verify your environment

mix mob.doctor

Deploy to everything you have connected

mix mob.deploy --native

Without --ios or --android, Mob targets all connected simulators, emulators, and physical devices at once — whatever is available. On macOS it includes both platforms; on Linux/Windows it deploys Android only. You don't need to tell it what you have.

If you have a physical iPhone (one-time setup)

Before your first deploy to a physical iPhone, register your app with Apple:

mix mob.provision

Then deploy normally — Mob auto-detects the phone alongside any running simulators or emulators and pushes to all of them in one command:

mix mob.deploy --native

If you only want to target the phone and skip the simulators for a deploy:

mix mob.deploy --native --ios

Targeting one device at a time

Use mix mob.devices to see what's connected and their IDs, then --device <id> to target a specific one — useful when you have multiple physical devices or want to isolate a deploy while keeping others running.


LiveView projects

Instead of writing screens in Elixir with the ~MOB sigil, you can run a full Phoenix LiveView app inside a native WebView. The native shell handles device APIs and distribution; your UI is a regular Phoenix web app.

The first-run flow is not the same as a native project — it has database setup, an extra asset-pipeline step, and a couple of paths to fill in by hand. The full sequence is below.

Mixed apps are fine

You don't have to pick one mode for the whole app. A native Mob project can host LiveView screens (run mix mob.enable liveview in an existing project), and a LiveView project can include native Mob.Screen modules alongside its WebView screens. Use whichever fits each part of the app.

One thing to be aware of: a mixed app has two distinct forms of navigation.

  • Phoenix routeslive "/foo", FooLive in router.ex, navigated with <.link navigate={...}> or push_navigate(...). Lives entirely inside the LiveView WebSocket; the WebView's URL changes but the native nav stack doesn't.
  • Native navigationMob.Nav.push/2, pop/1, tab bars, drawers. Lives in the native nav controller; the WebView is just one screen on that stack.

The two stacks don't talk to each other (by default but you control both sides so if you really want to you could make that happen). A Phoenix route change inside a WebView doesn't push a native screen, and a Mob.Nav.push doesn't navigate the WebView. Plan crossings explicitly: a tap inside the LiveView that should push a native screen sends a mob_message event up to the hosting Mob.Screen, which calls Mob.Nav.push/2; a native back-button in a parent screen pops the WebView screen as a whole, not the route inside it.

Extra prerequisite

You need the phx_new archive in addition to mob_new:

mix archive.install hex phx_new

Create a LiveView project

Pass --liveview to mix mob.new:

mix mob.new my_app --liveview
cd my_app

--liveview combines with --ios or --android if you want a single-platform LiveView project — for example mix mob.new my_app --liveview --ios skips Android scaffolding entirely.

This calls mix phx.new under the hood, then patches the generated project: adds the Mob bridge hook to app.js, inserts the mob-bridge element in root.html.heex, adds Mob.App to the supervision tree, and writes a mob.exs with liveview_port: 4000.

1. Configure local paths

Unlike a native project, the LiveView template doesn't auto-fill machine- specific paths. Open these two files and set the values for your machine.

mob.exs — set both keys:

  • mob_dir — local path to the mob library (or deps/mob if vendored)
  • elixir_lib — your Elixir lib dir, e.g. ~/.local/share/mise/installs/elixir/1.19.5-otp-28/lib

android/local.properties — set the Android SDK path:

sdk.dir=/Users/you/Library/Android/sdk

2. Run first-time setup

mix mob.install

Caches the OTP runtimes, generates a placeholder app icon, and finalises the build config.

3. Configure and create the database

Edit config/dev.exs to point at your dev database (the Phoenix-generated defaults work for most local Postgres setups), then:

mix ecto.create && mix ecto.migrate

4. Run the Phoenix server once (required)

This downloads JS/CSS dependencies and compiles static assets. Skipping this step is the most common cause of a blank-screen first deploy — the WebView loads http://127.0.0.1:4000/ but the asset pipeline has never produced any files for it to serve.

mix phx.server

Open http://localhost:4000 in your browser to confirm it loads, then stop the server (Ctrl-C).

5. Deploy to device

mix mob.deploy --native

The native app starts your Phoenix server at http://127.0.0.1:4000/ and loads it in a WebView.

6. Verify the LiveView bridge

After the app launches, open the WebView in a remote inspector (Safari Web Inspector for iOS, chrome://inspect for Android) and run:

window.mob.send({some: 'event'})

The call should route through Phoenix's pushEvent — visible as a LiveView event on the server side — not through window.postMessage. That confirms the Mob ↔ LiveView bridge is wired correctly.

Day-to-day development

The workflow is the same as a native project — push changed BEAMs, restart, or watch for file changes:

mix mob.deploy    # push BEAMs + restart (Phoenix server restarts inside the app)
mix mob.watch     # auto-push on file save

Phoenix code changes (templates, LiveViews) are picked up automatically when the BEAM restarts. Asset changes (app.js, CSS) require running mix assets.build locally first, since the device runs your compiled assets, not the dev pipeline.

Adding LiveView to an existing native project

If you already have a Mob project (created without --liveview) and want to turn it into a LiveView app, run:

mix mob.enable liveview

This is the same patcher that mix mob.new --liveview runs for new projects: generates lib/<app>/mob_screen.ex, injects MobHook into assets/js/app.js, inserts the hidden <div id="mob-bridge"> into root.html.heex, sets liveview_port in mob.exs, and adds the Android networkSecurityConfig that lets the WebView reach 127.0.0.1. See LiveView Mode for the full architecture explanation.


After the first deploy

These commands work the same regardless of platform.

Connect a live IEx session

mix mob.connect

Tunnels Erlang distribution and drops you into an IEx session connected to the running BEAM on the device. You can inspect state, call functions, and push code live.

Node.list()
#=> [:"my_app_ios@127.0.0.1"]

Mob.Test.assigns(:"my_app_ios@127.0.0.1")
#=> %{count: 0, safe_area: %{top: 62.0, ...}}

Hot-push a code change

Edit a module, then push the new bytecode without restarting:

mix mob.push

Changed .beam files are loaded directly into the running BEAM via RPC — no restart, no state loss. The screen updates immediately.

Auto-push on save

mix mob.watch

Watches for file changes and runs mob.push automatically. Combine with mix mob.connect to keep an IEx session open alongside.


Deployment reference

CommandRestarts?Requires dist?What it does
mix mob.deploy --nativeYesNoBuild native binary + install + push BEAMs
mix mob.deployYesNoPush BEAMs + restart (no native rebuild)
mix mob.pushNoYesHot-push changed BEAMs via RPC
mix mob.watchNoYesmob.push on every file save
nl(MyApp.Screen) in IExNoYesHot-push a single module

Requires dist means Erlang distribution must be active. Run mix mob.connect first, or use the dashboard (mix mob.server) which sets it up automatically.

Which command should I use?

  • First time, or after changing Swift/Kotlin/C?mix mob.deploy --native
  • Changed Elixir, want a clean restart?mix mob.deploy
  • Changed Elixir, want to keep app state?mix mob.push
  • Want changes pushed automatically while editing?mix mob.watch

Toolchain managers

Mob is tested against mise for managing Elixir and Erlang versions. The repos ship a .tool-versions file that mise reads automatically.

asdf uses the same .tool-versions format and should work without changes — install Elixir/Erlang the asdf way and you're done. We don't actively test it, but no Mob code touches mise or asdf directly; everything works off whatever mix, elixir, iex, and erl resolve to on your PATH.

Nix users need to set a few env vars yourself, since mob_dev's auto- detection assumes mise/asdf-style on-disk layouts (e.g. ~/.local/share/mise/ installs/elixir/...). Set them in your shell, direnv, or shell.nix before running mix mob.install — the install step reads them and bakes the resolved values into mob.exs and android/local.properties. Setting them later still works (build.sh and Gradle re-read the env at deploy time), but you'll need to edit those config files by hand.

Env varRead byWhen to set
MOB_ELIXIR_LIBmob.install (writes into mob.exs); iOS build.shbefore mob.install
MOB_DIRmix mob.new --local (path resolution); iOS build.shbefore mob.new (only if using --local)
MOB_DEV_DIRmix mob.new --local (path resolution)before mob.new (only if using --local)
MOB_CACHE_DIROTP downloader at install + any --native deploybefore mob.install
MOB_SIM_RUNTIME_DIRiOS build.sh (writer) and mob_beam.m (reader)before first mob.deploy --native
ANDROID_HOMEmob.install (auto-detected, written to local.properties); Gradlebefore mob.install
JAVA_HOMEGradlebefore mob.deploy --native

Each var has a default if you don't set it; the table column says where each would land:

  • MOB_ELIXIR_LIB — computed from the running BEAM (mise/asdf path)
  • MOB_DIR / MOB_DEV_DIR — resolves from mob.exs or deps/mob, or sibling discovery (./mob_dev then ../mob_dev)
  • MOB_CACHE_DIR~/.mob/cache/
  • MOB_SIM_RUNTIME_DIR~/.mob/runtime/ios-sim/
  • ANDROID_HOME — read from android/local.properties sdk.dir

Quick recipe for a Nix user with Elixir from pkgs.beam.packages.erlang_28.elixir_1_19. Put this in direnv or shell.nix so it loads on cd:

export MOB_ELIXIR_LIB="$(elixir -e 'IO.puts(Path.dirname(to_string(:code.lib_dir(:elixir))))')"
export MOB_CACHE_DIR="$HOME/.mob/cache"           # or somewhere your Nix gc-roots manage
export MOB_SIM_RUNTIME_DIR="$HOME/.mob/runtime/ios-sim"
export ANDROID_HOME="$HOME/Android/Sdk"           # wherever your nixpkgs AndroidSdk lives

Then run the normal flow:

mix mob.new my_app --ios
cd my_app
mix mob.install      # picks up the env vars, bakes them into mob.exs / local.properties
mix mob.deploy --native

mix mob.cache and mix mob.cache --clear know about both MOB_CACHE_DIR and MOB_SIM_RUNTIME_DIR overrides — if you point them at a project-local or sandbox-friendly path, that's also what cache listings and --clear will target.


Caches and disk usage

mix mob.deploy populates a few machine-wide locations outside your project tree:

  • ~/.mob/cache/ — pre-built OTP runtimes for iOS sim, iOS device, and Android (one per ABI). Reused across every Mob project. ~200–400 MB each. Override with MOB_CACHE_DIR.
  • ~/.mob/runtime/ios-sim/ — the OTP root that the running iOS simulator app reads from at startup (mob_new ≥ 0.1.20). One per machine, not per project — last project deployed wins. Override with MOB_SIM_RUNTIME_DIR. Older projects use /tmp/otp-ios-sim instead, which mob.cache still lists.
  • ~/Library/Caches/elixir_make/ (macOS) or ~/.cache/elixir_make/ (Linux) — pre-built NIF tarballs that exqlite and other NIF deps download instead of recompiling from source. Owned by elixir_make, not Mob.

To inspect or clear them:

mix mob.cache                              # show paths + sizes (read-only)
mix mob.cache --include-transitive         # also show elixir_make's cache
mix mob.cache --clear                      # delete Mob's caches (with prompt)
mix mob.cache --clear --include-transitive # delete ours + elixir_make's

To relocate Mob-owned paths (sandbox-friendly for Nix or CI environments):

export MOB_CACHE_DIR=/path/to/cache         # OTP runtime cache
export MOB_SIM_RUNTIME_DIR=/path/to/runtime # iOS simulator runtime

mob.cache deliberately does not touch ~/.hex, ~/.mix, ~/.gradle, or Xcode's DerivedData — those are shared with non-Mob projects and clearing them via Mob would silently break unrelated work.


Your first screen

defmodule MyApp.HomeScreen do
  use Mob.Screen

  def mount(_params, _session, socket) do
    {:ok, Mob.Socket.assign(socket, :count, 0)}
  end

  def render(assigns) do
    ~MOB"""
    <Column padding={24} gap={16}>
      <Text text={"Count: #{assigns.count}"} text_size={:xl} />
      <Button text="Tap me" on_tap={tap(:increment)} />
    </Column>
    """
  end

  def handle_info({:tap, :increment}, socket) do
    {:noreply, Mob.Socket.assign(socket, :count, socket.assigns.count + 1)}
  end

  def handle_info(_message, socket), do: {:noreply, socket}
end

mount/3 initialises assigns. render/1 returns the component tree via the ~MOB sigil. handle_info/2 updates state in response to user events. After each update, the framework calls render/1 again and pushes the diff to the native layer.


Next steps