The Stack That Almost Worked
Daniel Bergholz built CourseShelf, a SaaS for online courses, on a stack that seemed ideal: Elixir/Phoenix on the backend, React on the frontend, and Inertia.js as the glue. For a year, it felt like the best of both worlds. Then reality hit.
The Cracks Appear: Three Files vs. One
The first sign of trouble was the sheer number of layers needed for a simple feature. A course listing page required three files:
- A controller that serializes data into props
- A JSON serializer module that manually converts Elixir structs to plain maps
- A React component that receives those props and renders them
Plus a hand-maintained TypeScript type file. Here's the serializer from v1:
# lib/courseshelf_web/controllers/course_json.ex (v1)
defmodule CourseshelfWeb.CourseJSON do
alias Courseshelf.Courses.Course
def serialize(%Course{} = course) do
%{
id: course.id,
title: course.title,
# ...every single field, by hand
tags: TagJSON.serialize(course.tags),
channel: ChannelJSON.serialize(course.channel),
platform: PlatformJSON.serialize(course.platform)
}
end
end
In contrast, the LiveView version packs everything into one file:
# lib/course_shelf_web/live/course_live/index.ex (v2)
defmodule CourseShelfWeb.CourseLive.Index do
use CourseShelfWeb, :live_view
alias CourseShelf.Courses
@page_size 15
@impl true
def mount(_params, _session, socket) do
{:ok, socket
|> assign(:page_title, gettext("Courses"))
|> assign(:page_url, url(~p"/courses"))}
end
@impl true
def handle_params(params, _uri, socket) do
search = params |> Map.get("search", "") |> to_string()
page = parse_page(params["page"])
result = Courses.list_active_courses(page: page, page_size: @page_size, search: search)
{:noreply, socket
|> assign(:search, search)
|> assign(:page, result.page)
|> assign(:courses, result.entries)}
end
@impl true
def render(assigns) do
~H"""
<.course_card :for={course <- @courses} course={course} from={@current_url} />
"""
end
end
One file. Query, assign, loop. No serialization, no separate type definitions.
AI Agents Couldn't Write Inertia Code
The real dealbreaker came when Bergholz started using AI coding agents. Not a single agent could produce working Inertia code. The pattern was too niche, especially the Elixir port of Inertia. "I'd ask for a feature, and the agents would just keep spinning," he writes. This forced him back to hand-writing every layer — a time bomb for a solo developer.
The Infrastructure Tax: Node.js Workers Eating RAM
CourseShelf needed server-side rendering for SEO. With Inertia, that meant a pool of Node.js workers. The Inertia Phoenix library spins up four workers by default, each consuming ~150MB. Total: 600MB just for rendering. Add Elixir's baseline of ~200-300MB, and on a 1GB RAM server, memory usage hovered at 90-100%, causing random OOM crashes.
After switching to LiveView, the same server idles at ~250MB — a quarter of what was used before. No more crashes.
The Database Cost: From $38/mo to $7/mo
Bergholz also dropped managed Postgres on Fly.io (costing ~$38/month) for unmanaged Postgres at ~$7/month. He added Tigris (S3-compatible object storage) for backups, costing "essentially nothing." For a SaaS with ~89 accounts and ~20 daily active users, the savings are significant.
But What About Rich UI?
Skeptics ask: doesn't LiveView lose frontend interactivity? Bergholz demonstrates that Phoenix provides Phoenix.LiveView.JS for client-side toggles without server round-trips:
JS.hide(to: "#description-truncated")
|> JS.show(to: "#show-less", display: "inline-flex")
|> JS.hide(to: "#show-more")
}>
Show more
For drag-and-drop, he uses colocated JavaScript hooks. The SortableJS library is dropped into assets/vendor/ and exposed on window, then referenced in a hook right next to the LiveView component.
The Verdict
Bergholz's migration took 345 commits. The result: simpler code, lower infrastructure costs, and AI-agent-friendly patterns. For solo devs or small teams building server-rendered apps, this case study makes a strong argument for full-stack Elixir over mixed stacks.
What You Should Do
If you're considering LiveView, start with a small non-critical feature. The developer experience is real, but the ecosystem for rich client-side interactions is different — you trade React's vast component libraries for a smaller but sufficient set of tools. For AI-assisted coding, LiveView's single-file pattern may be a better fit.
