4 min read
Aaron Reisman Aaron Reisman

Elegant Error Handling in Phoenix LiveView with Ash

How to build 404, 403, and 500 pages in Phoenix LiveView that do not feel like afterthoughts. Uses Ash exceptions and the built-in Phoenix conventions so the failure paths read like the rest of your product.

Last updated

Good products think about their failure states. A blank 500 page, or that classic no view was found for the format: 'html' error, yanks people right out of the experience. Those moments are part of the product too. Even a 500 page should feel like it knows what it is.

This is how I design 404, 403, and 500 experiences in a Phoenix LiveView app that uses Ash. Some of it is engineering. Some of it is just paying attention to tone. The status code is the easy part. The rest is what people actually remember.

Why failure states deserve design time

  • How you recover tells people how you build everything else.
  • One error can ripple across Phoenix, LiveView, and Ash, so you need to be clear about who owns what.
  • The copy and layout should feel like the rest of your product, not a fallback page.
  • And honestly, getting these right early saves a lot of debugging later.

How it fits together

  1. Phoenix Endpoint picks which renderer to use.
  2. Error layout and templates set the tone.
  3. Ash exceptions raise the right HTTP status (404, 403, 500) when bang functions fail.
  4. LiveView inherits all of the above without extra wiring.

Once those click, the rest is mostly config.

1. Endpoint config

Tell Phoenix which HTML and JSON modules to use. This goes in config/config.exs:

  config :my_app, MyAppWeb.Endpoint,
  render_errors: [
    formats: [html: MyAppWeb.ErrorHTML, json: MyAppWeb.ErrorJSON],
    layout: {MyAppWeb.Layouts, :error}
  ]

This keeps the response format stable and gives your error pages their own layout.

2. The layout

I keep the error layout small. No nav, no extra chrome. Just somewhere quiet for the reader to land. It lives in lib/my_app_web/components/layouts/error.html.heex:

  <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{Phoenix.Controller.status_message_from_template("#{@status}.html")} · MyApp</title>
</head>
<body>
  <main>
    {@inner_content}
  </main>
</body>
</html>

The dynamic title keeps search snippets clean and works well with screen readers.

3. Microcopy that says something

Match each status to the feeling you want. I aim for calm, plus a clear next step.

404 (lib/my_app_web/controllers/error_html/404.html.heex):

  <section>
  <h1>{@status}</h1>
  <p>{Phoenix.Controller.status_message_from_template("#{@status}.html")}</p>
  <p>Sorry, the page you're looking for doesn't exist or has moved.</p>
  <.link href="/">Return to Home</.link>
</section>

500:

  <section>
  <p>{Phoenix.Controller.status_message_from_template("#{@status}.html")}</p>
  <p>Something went wrong on our end. We're on it!</p>
</section>

403:

  <section>
  <p>{Phoenix.Controller.status_message_from_template("#{@status}.html")}</p>
  <p>You don't have permission to access this resource.</p>
</section>

4. Keep rendering boring on purpose

The ErrorHTML module is meant to be boring. Let Phoenix do the wiring so you can focus on the copy:

  defmodule MyAppWeb.ErrorHTML do
  use MyAppWeb, :html
  embed_templates "error_html/*"

  def render(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

Where Ash comes in

Ash’s bang functions (read!, create!, update!) handle both data checks and authorization. When something fails, they raise a typed exception, and Phoenix turns that into the right HTTP status. No glue code. No nested conditionals.

Use them in LiveViews exactly like you would on the server:

  def mount(%{"id" => id}, _session, socket) do
  post = MyApp.Content.get_post!(id, actor: socket.assigns.current_user)
  {:ok, assign(socket, :post, post)}
end

Missing record? Permission denied? Ash raises the right exception, Phoenix renders the right template. The translation happens in the Plug Exception implementation if you want to see it.

What each one looks like in practice

Not Found (404):

  def handle_event("view_post", %{"id" => id}, socket) do
  post = MyApp.Content.get_post!(id, actor: socket.assigns.current_user)
  {:noreply, push_navigate(socket, to: ~p"/posts/#{post.id}")}
end

Forbidden (403):

  def handle_event("comment_on_post", params, socket) do
  MyApp.Content.create_comment!(params, actor: socket.assigns.current_user)
  {:noreply, put_flash(socket, :info, "Comment added!")}
end

Server Error (500):

  def assign_posts(socket) do
  try do
    posts = MyApp.Content.list_posts!(actor: socket.assigns.current_user)
    assign(socket, :posts, posts)
  rescue
    error ->
      Logger.error("Error loading posts: #{inspect(error)}")
      raise error
  end
end

A few habits that help

  • Re-read the copy every few months. Make sure it still sounds like the rest of your product.
  • Log the real error. Keep enough detail to debug, but don’t show internals to users.
  • Keep one source of truth. Centralize rendering so emails and APIs pick up the same tone for free.
  • Watch the visuals. Typography, spacing, buttons. Error views should look like they belong to your app.

Why this holds up

  • Composable. Plug and Ash exceptions stack instead of forking.
  • Format aware. Phoenix returns JSON or HTML from the same setup, so nothing gets duplicated.
  • Consistent voice. Templates, layout, and copy still sound like your product, even when something failed.
  • Easy to evolve. New error types slot in without touching controllers.

The messy edges are what people remember. When a LiveView hits a problem, the page they see should still feel like your product, just calmer. Clear copy and a useful next step is usually enough. That’s what makes a product feel reliable.

About the author

Aaron Reisman

Aaron Reisman

Aaron Reisman is a software engineer who writes about React, Elixir, and the parts of products that decide how they actually feel.

More like this