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.
Good products think about their failure states. A blank 500 page, or the raw error Phoenix shows when it has no error view set up, 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 pages in a Phoenix LiveView app that uses Ash. If you haven’t used Ash, it’s an Elixir framework where you describe your data and its rules (who can read what, what counts as valid) in one place, and Ash enforces them for you. You don’t need to know much more than that to follow along.
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 pass through Phoenix, LiveView, and Ash before anyone sees it, so you need to be clear about which layer does 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
- Phoenix Endpoint picks which renderer to use.
- Error layout and templates set the tone.
- Ash exceptions carry the right HTTP status (404, 403, 500) when an Ash call fails.
- LiveView gets the same pages for free when the error happens during
mount.
Once those click, the rest is mostly config. There is one catch with LiveView events, and I’ll get to it near the end.
Point Phoenix at your error modules
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}
]
Now browser requests get an HTML error page and API requests get JSON, and the HTML pages use their own layout instead of your app’s normal one.
Give errors their own 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 title shows the status message, like “Not Found · MyApp”, so the browser tab and screen readers say what happened.
Write copy 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>
Keep the rendering boring
The ErrorHTML module from the config should do almost nothing. embed_templates picks up the template files above, and render/2 is the fallback for any status that has no template:
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
So a 404 gets your custom page, and an odd status like 418 still gets a plain “I’m a teapot” instead of a crash.
Where Ash comes in
In Elixir, a function whose name ends in ! raises an exception on failure instead of returning {:error, reason}. Ash follows that convention. Its bang functions (read!, create!, update!) check that the record exists and that the current user is allowed to touch it. When either check fails, they raise an Ash exception. No glue code. No nested conditionals.
Below, MyApp.Content is an Ash domain (a group of related resources), and get_post! is a function Ash generates for it. The actor: option tells Ash who is asking, so it can apply your authorization rules, which Ash calls policies. Call it in mount the same way you would in a controller:
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, and Phoenix renders the right template.
How does Phoenix know a “not found” exception means 404? Through Plug.Exception, a protocol that lets any exception declare its HTTP status. The ash_phoenix package implements it for Ash’s errors: not found becomes 404, a policy failure becomes 403, and so on. Make sure ash_phoenix is in your dependencies. Without it, every Ash error shows up as a 500.
The catch: errors inside events
Everything above works because the exception happens during the first mount, while Phoenix is still answering a normal HTTP request. A LiveView then opens a WebSocket and keeps a server process running for that page. After that point, the rules change. An exception in handle_event crashes the LiveView process. The client sees the crash and remounts the LiveView over the same connection, without a page reload. The user does not see your 404 or 403 page. They see the current page come back with fresh state.
That is still a safe default. Nothing leaks, and the error is logged. But it means the examples below are about where the exception comes from, not about which page the user sees.
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
If the post was deleted after the list loaded, get_post! raises a not-found error and the LiveView remounts.
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
If your policies don’t let this user comment, create_comment! raises and the comment is not saved. If you want the user to see a message here, use the non-bang create_comment and handle {:error, error} with a flash.
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
This one logs with some context and then re-raises, so the failure still follows the normal path.
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.
- Render errors in one place. Keep it all in
ErrorHTMLandErrorJSON, so every status gets the same layout and tone. - Watch the visuals. Typography, spacing, buttons. Error views should look like they belong to your app.
Why this holds up
- No special cases. Ash errors declare their own status through
Plug.Exception, so Phoenix handles them like any other exception. - 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 change. Add a template like
422.html.heexand Phoenix uses it. No controller changes.
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.
