Build Your First App

Copy Markdown

New to Elixir, Phoenix, or Aurora UIX? Start here. This is a complete, copy-paste walkthrough that takes you from nothing installed to a running CRUD application in your browser — no prior Elixir or Phoenix experience required.

By the end you'll have a small Inventory app with two screens generated by Aurora UIX: a list of categories and a list of products, each with create, edit, and detail views, validation, and a category picker on the product form — all from a few lines of declarative configuration.

Already have a Phoenix app?

If you're an experienced Phoenix developer and just want to add Aurora UIX to an existing project, skip this tutorial and read the concise Getting Started guide instead.

Plan for ~20 minutes. Each step is a command or a block of code you can paste verbatim.


1. What you'll build

A two-table inventory manager:

  • Categoryname, description. A category groups products.
  • Productname, price, stock, and a link back to a Category.

Aurora UIX reads metadata about these tables and generates the full UI: paginated lists, detail (show) pages, and create/edit forms — including a dropdown to pick a product's category, because it understands the relationship between the two tables.

You will:

  1. Install the toolchain (Elixir, Erlang, Phoenix).
  2. Create a fresh Phoenix app.
  3. Add the data layer — pick Phoenix + Ecto or Ash (both are shown).
  4. Add Aurora UIX and its assets.
  5. Describe the UI with Aurora UIX metadata.
  6. Add routes and run it.

2. Install the toolchain

Aurora UIX runs on Elixir (the language), which runs on Erlang/OTP (the virtual machine), inside a Phoenix web application. You need all three.

Target versions:

  • Elixir 1.17+
  • Erlang/OTP 28+
  • Phoenix 1.8+

If you're a seasoned developer, the cleanest way to install and pin Elixir and Erlang is a version manager. It reads a .tool-versions file so every project gets exactly the versions it expects — much better than a bare system install:

  • mise — modern, fast, single binary.
  • asdf — the long-standing standard.

Install one of those, then add the Elixir and Erlang plugins and install the versions above. (We won't reproduce their setup steps here — follow the linked docs; it's a few commands.)

Or install directly

If you don't use a version manager, follow the official installers:

Install the Phoenix project generator

Once Elixir is available, install the Phoenix app generator and Hex (the package manager):

mix local.hex
mix archive.install hex phx_new

Verify everything is in place:

elixir --version
mix phx.new --version

You also need PostgreSQL running locally — Phoenix uses it as the default database. Install it from postgresql.org if you don't have it.


3. Create a new Phoenix app

Generate a fresh app called inventory, fetch its dependencies, and create the database:

mix phx.new inventory
cd inventory
mix ecto.create

Start it once to confirm a blank app runs:

mix phx.server

Visit http://localhost:4000 — you should see the Phoenix welcome page. Stop the server with Ctrl+C twice.

What did that generate?

A Phoenix app is just a folder of Elixir code. The parts you'll touch in this tutorial:

  • lib/inventory/ — your business logic (data, database access).
  • lib/inventory_web/ — your web layer (pages, routes, components).
  • lib/inventory_web/router.ex — maps URLs to code.
  • assets/css/app.css and assets/js/app.js — your styles and JavaScript.
  • mix.exs — your project's dependencies.

4. Add the data layer

This is the only step that differs by backend. Aurora UIX works with both plain Ecto schemas and Ash resources and detects which you're using automatically — so everything after this step is identical either way.

Pick one option below. Both create the same two tables with the same field names, grouped under a context/domain called Inventory.Catalog with schemas Inventory.Catalog.Category and Inventory.Catalog.Product. Because the module names match, the next steps are byte-for-byte identical for both options.

New to all of this? Choose Option A. It has the least to learn. Building something closer to a production app? Option B (Ash) is what many real Aurora UIX apps use.

Why a context named Catalog, not Inventory?

Phoenix refuses to create a context with the same name as your app (Inventory), so we group these schemas under Inventory.Catalog. This also lines the Ecto path up perfectly with the Ash domain in Option B.

Option A — Phoenix + Ecto

Generate the two schemas (tables + Ecto schema modules). We use phx.gen.schema — not phx.gen.context — because Aurora UIX gets its CRUD functions from a tiny helper library, aurora_ctx, in a moment:

mix phx.gen.schema Catalog.Category categories name description
mix phx.gen.schema Catalog.Product products \
  name price:decimal stock:integer category_id:references:categories

The generator creates lib/inventory/catalog/category.ex and lib/inventory/catalog/product.ex (plus migrations), but it does not wire up the relationship. Open lib/inventory/catalog/product.ex and, inside the schema block, replace the generated field :category_id, :id line with a real association:

belongs_to :category, Inventory.Catalog.Category

…and add :category_id to the changeset's cast/2 list so the form can set it:

|> cast(attrs, [:name, :price, :stock, :category_id])

In lib/inventory/catalog/category.ex, add the other side of the relationship inside its schema block:

has_many :products, Inventory.Catalog.Product

Now add aurora_ctx to your mix.exs dependencies — it generates the CRUD functions (list_products/1, get_product/2, create_product/2, …) that Aurora UIX expects:

{:aurora_ctx, "~> 0.1"}

Run mix deps.get, then create the context module at lib/inventory/catalog.ex:

defmodule Inventory.Catalog do
  use Aurora.Ctx

  alias Inventory.Catalog.{Category, Product}
  alias Inventory.Repo

  ctx_register_schema(Category, Repo)
  ctx_register_schema(Product, Repo)
end

Finally, create the tables:

mix ecto.migrate

Why aurora_ctx?

Aurora UIX drives the UI through context functions like list_products/1. The default mix phx.gen.context generates list_products/0 (no arguments), which doesn't match. aurora_ctx's ctx_register_schema/2 generates the full set of compatible functions for you in two lines. You can still add your own custom functions to this module as your app grows.

That's it — skip ahead to "Either way" below.

Option B — Ash

Ash is a declarative resource framework. Install it with its igniter-powered installers, which scaffold the repo, config, and supervision tree for you:

mix igniter.install ash ash_postgres ash_phoenix

Then define a domain and two resources. Create lib/inventory/catalog.ex:

defmodule Inventory.Catalog do
  use Ash.Domain

  resources do
    resource Inventory.Catalog.Category
    resource Inventory.Catalog.Product
  end
end

Create lib/inventory/catalog/category.ex:

defmodule Inventory.Catalog.Category do
  use Ash.Resource,
    domain: Inventory.Catalog,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "categories"
    repo Inventory.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :name, :string, allow_nil?: false, public?: true
    attribute :description, :string, public?: true
    timestamps()
  end

  relationships do
    has_many :products, Inventory.Catalog.Product, public?: true
  end

  actions do
    defaults [:read, :destroy, create: :*, update: :*]
  end
end

Why public?: true?

Ash attributes are private by default — they won't be accepted by the create/update actions or rendered by the form unless you mark them public?: true. Forgetting this is the most common reason an Ash form "saves nothing."

Create lib/inventory/catalog/product.ex:

defmodule Inventory.Catalog.Product do
  use Ash.Resource,
    domain: Inventory.Catalog,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "products"
    repo Inventory.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :name, :string, allow_nil?: false, public?: true
    attribute :price, :decimal, public?: true
    attribute :stock, :integer, public?: true
    timestamps()
  end

  relationships do
    belongs_to :category, Inventory.Catalog.Category,
      public?: true,
      attribute_writable?: true
  end

  actions do
    defaults [:read, :destroy, create: :*, update: :*]
  end
end

attribute_writable?: true on the belongs_to lets the form set the category by its id (category_id) — which is exactly what the dropdown you'll build in Step 6 does.

Generate and run the migrations for these resources:

mix ash_postgres.generate_migrations create_inventory
mix ecto.migrate

For this tutorial, use the domain (Inventory.Catalog) as the "context" and the resources (Inventory.Catalog.Product, Inventory.Catalog.Category) in the next step.

Using Ash policies?

We kept this resource open to stay simple. If your resources are protected by Ash.Policy.Authorizer, add ash_actor_assign: :current_user to the metadata so Aurora UIX threads the current user as the Ash actor through every generated CRUD call. See Ash Integration → Authorization & policies.

Either way — you're now on the shared path

One backend, almost-identical UI

From here on the steps are the same whether you chose Ecto or Ash — both options gave you a context/domain Inventory.Catalog with Inventory.Catalog.Category and Inventory.Catalog.Product. The only difference is one line in Step 6: how each resource is declared (context:/schema: for Ecto vs ash_resource: for Ash). Everything else — layouts, the category dropdown, routes, assets — is identical, because Aurora UIX detects the backend automatically.


5. Add Aurora UIX

Add the dependency to mix.exs:

def deps do
  [
    # ...your other deps...
    {:aurora_uix, "~> 0.1"}
  ]
end

Fetch it:

mix deps.get

Aurora UIX ships pre-built CSS themes, a set of icons, and a few JavaScript hooks. Wire them up once:

mix auix.gen.stylesheet
mix auix.gen.icons

In assets/css/app.css, add the imports at the end of the file — variables first, then the rules last so Aurora UIX's component styles win. Use a relative ./ path on each import (Tailwind v4 requires it):

/* Aurora UIX — add at the end of app.css */
@import "./auix-icons.css";
@import "./auix-variables.css";        /* Aurora UIX defaults */
@import "./auix-bridge-daisyui.css";   /* optional: follow your daisyUI theme */
@import "./auix-rules.css";            /* must be last */

Already using Heroicons?

A fresh Phoenix 1.8 app already ships Heroicons. In that case you can skip mix auix.gen.icons and instead add @source "../../deps/aurora_uix/priv/static/classes.js"; near the top of app.css so Tailwind picks up only the icon classes Aurora UIX needs. Either approach works.

In assets/js/app.js, import Aurora UIX's hooks and spread them into your LiveSocket:

import {Hooks as auroraHooks} from "../../deps/aurora_uix/assets/js/hooks.js"

const liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  hooks: {...auroraHooks},   // alongside your own hooks, if any
})

Want the details — or your own theme?

The asset setup above is the short version. For the full explanation (including how to inherit your daisyUI theme automatically so Aurora UIX matches your brand with zero extra work), see Getting Started → CSS Configuration and the Writing a Style Bridge guide.


6. Describe the UI

Now the fun part. You describe each resource once with Aurora UIX metadata, and it generates the index, show, and form views. We'll put both resources in a single module — this matters: for the product form to render the category as a dropdown, Aurora UIX needs the related resource (:category) registered alongside :product in the same module.

Create lib/inventory_web/catalog_views.ex:

defmodule InventoryWeb.CatalogViews do
  use Aurora.Uix

  alias Inventory.Catalog
  alias Inventory.Catalog.{Category, Product}

  # Ecto (Option A): context: Catalog, schema: Category
  # Ash  (Option B): ash_resource: Category
  auix_resource_metadata :category, context: Catalog, schema: Category do
    field :name, placeholder: "Category name", required: true
    field :description, max_length: 255
  end

  # Ecto (Option A): context: Catalog, schema: Product
  # Ash  (Option B): ash_resource: Product
  auix_resource_metadata :product, context: Catalog, schema: Product do
    field :name, placeholder: "Product name", required: true
    field :price, precision: 12, scale: 2
    field :stock, required: true
    # Render the belongs_to as a dropdown, labelled by the category's name:
    field :category_id, option_label: :name
  end

  auix_create_ui do
    index_columns :category, [:name, :description]

    edit_layout :category do
      inline [:name, :description]
    end

    index_columns :product, [:name, :price, :stock]

    edit_layout :product do
      stacked do
        inline [:name]

        sections do
          section "Pricing" do
            stacked [:price]
          end

          section "Inventory" do
            stacked [:stock, :category_id]
          end
        end
      end
    end
  end
end

Two things to notice about the product's category picker:

  • You reference the foreign-key field :category_id (not :category) in both the metadata and the layout. Aurora UIX renders it as a <select> because it recognises the belongs_to.
  • field :category_id, option_label: :name tells it to label each option with the category's name instead of its raw id.

There's a lot more you can do here

This is a deliberately small slice. To learn every field option (validation, readonly, hidden fields, custom renderers, associations) see the Resource Metadata guide, and to build richer forms with tabs, nested sections, and one-to-many lists see the Layouts guide.


7. Add routes and run it

Open lib/inventory_web/router.ex and add the generated LiveViews to the browser scope using Aurora UIX's route helper, which expands to the full set of CRUD routes:

import Aurora.Uix.RouteHelper

scope "/", InventoryWeb do
  pipe_through :browser

  auix_live_resources "/categories", CatalogViews.Category
  auix_live_resources "/products", CatalogViews.Product
end

The generated LiveView modules are named after the views module plus the schema name — InventoryWeb.CatalogViews.Category and InventoryWeb.CatalogViews.Product — which is exactly what the route helper references here (the InventoryWeb part comes from the surrounding scope).

Start the server:

mix phx.server

Now visit:

You have a complete, responsive CRUD app — list, detail, create, and edit views, with validation and real-time updates — and you wrote almost no UI code. 🎉

Generate routes for one resource at a time

auix_live_resources/2 accepts only: / except: to limit which routes it creates (for example, a read-only screen). See Getting Started → Add Routes to Router.


Where to go next

You now have a working app and the core workflow: describe a resource → lay out its views → add routes. From here, deepen each piece: