We had a booking system for physical spaces — rooms, properties, that kind of thing. The architecture looked like every “modern” web app circa 2022:
- Elixir/Phoenix backend exposing a GraphQL API
- Next.js frontend for the admin dashboard
- Custom JavaScript integrations on client websites to hit the GraphQL API for public bookings
It worked. But every feature required touching three codebases. Want to add an addon quantity selector to the booking form? Write the GraphQL mutation, update the dataloader, add the resolver, generate TypeScript types, build the React component, wire up Apollo cache invalidation, write separate tests for each layer. A feature that should take a morning takes two days.
The moment that pushed me over the edge was implementing a date-range availability check. The query needed Postgres range overlap operators, which meant a custom Ecto fragment, a new resolver, a new TypeScript type, a new Apollo hook, and React state management to keep the selected dates in sync with the availability response. All for something that’s fundamentally “user picks dates, server says yes or no.”
The rewrite
So I rewrote it. Deleted the GraphQL layer, the Next.js frontend, the client-side integrations. Replaced everything with Phoenix LiveView.
The app now has 55 LiveView modules, 27 test files, and zero separate frontend deployments.
I won’t pretend the rewrite was painless — it took a few weeks, solo, and there were moments where I missed the explicit API boundary that GraphQL gives you. But the velocity improvement after was immediately noticeable. The fact that one person could rewrite a three-codebase system in a few weeks says more about the framework than any benchmark.
Less ceremony per feature
Here’s a real add-on toggle from the public booking page. When a guest checks a box, the price recalculates — no API call, no loading spinner, no optimistic update logic:
def handle_event("toggle_addon", params, socket) do
with {:ok, %{addon_id: addon_id}} <- PublicBookingParams.validate(:addon_event, params) do
{selected_addons, selection_order} = toggle_addon_selection(socket, addon_id)
{:noreply,
socket
|> assign(selected_addons: selected_addons)
|> assign(addon_selection_order: selection_order)
|> recalculate_and_update_payment()}
end
end
The user clicks, the server updates state, the DOM patches over WebSocket. Near-instant. No client-side state management library. No cache invalidation. No TypeScript types to keep in sync.
In the old world, this was: GraphQL mutation → Apollo cache update → React state sync → re-render. Four moving parts instead of one.
One test covers the entire stack
This is the part that surprised me most. Here’s a real test from the availability selector:
test "excludes booked items from available list", %{conn: conn, org: org, item: item} do
item2 = insert(:item, organisation: org, title: "Suite")
booking = insert(:booking, organisation: org, status: :paid)
insert(:booking_item,
booking: booking, item: item, booking_status: :paid,
date_range: %Postgrex.Range{
lower: ~D[2026-01-01], upper: ~D[2026-01-03],
lower_inclusive: true, upper_inclusive: false
}
)
{:ok, view, _html} = live(conn,
"/#{org.identifier}/rooms?check_in=2026-01-01&checkout=2026-01-03")
refute has_element?(view, "[data-role=room-card]", item.title)
assert has_element?(view, "[data-role=room-card]", item2.title)
end
This single test verifies:
- Database: The overlap exclusion query works correctly
- Business logic: Paid bookings block availability
- Routing: The URL params are parsed and passed to the context
- Rendering: The correct rooms appear in the DOM
- Multi-tenancy: Only this org’s items are queried
In the old architecture, that’s four separate tests across three codebases. Here it’s eight lines.
Structural multi-tenancy
Every context function in the app takes a %Scope{} as its first parameter. This isn’t a convention — it’s enforced by pattern matching:
def list_available_items(%Scope{organisation_id: org_id}, check_in, checkout, opts \\ []) do
query_range = DateRangeType.to_postgrex_range(check_in, checkout)
booked_items_query =
from bi in BookingItem,
join: b in Booking, on: bi.booking_id == b.id,
where: b.status in [:unpaid, :paid, :complete],
where: fragment("? && ?", bi.date_range, ^query_range),
select: bi.item_id,
distinct: true
Item
|> where([i], i.organisation_id == ^org_id)
|> where([i], i.id not in subquery(booked_items_query))
|> Repo.all()
end
The && operator is Postgres’s native range overlap check — if you’re curious about how daterange types and exclusion constraints work under the hood, I wrote about that in Postgres Range Types & Exclusion Constraints.
If you try to call this without a Scope, the pattern match rejects it immediately with a FunctionClauseError. The LiveView passes the scope from the authenticated session. There’s no way to accidentally query across tenants because the function signature prevents it.
With GraphQL, tenant isolation was a resolver-level concern. Miss one where clause in one resolver and you’ve got a data leak. Here, it’s structural. I wrote more about this pattern in The Scope Pattern.
We test it explicitly:
test "enforces multi-tenant isolation", %{conn: conn, org: org} do
other_org = insert(:organisation)
_other_item = insert(:item, organisation: other_org, title: "Other Org Room")
{:ok, view, _html} = live(conn,
"/#{org.identifier}/rooms?check_in=2026-01-01&checkout=2026-01-03")
refute has_element?(view, "[data-role=room-card]", "Other Org Room")
end
Real-time validation without the ceremony
As the user types, the server validates against the same Ecto changeset that will validate the final submission:
def handle_event("validate", %{"booking_form" => params}, socket) do
changeset =
PublicBookingParams.changeset(:form, params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset, as: "booking_form"))}
end
The validation rules are defined once. They run on every keystroke. The errors render inline. No Zod schema that might drift from your backend validation. No async validation endpoint. One source of truth.
This was something that always bothered me with the old architecture — we’d have Yup validation on the frontend and Ecto validation on the backend, and they’d inevitably drift. A field would be required on the backend but optional on the frontend, and users would submit forms that the API rejected with unhelpful errors.
The LiveView is thin, the context is fat
Our LiveViews have zero Repo calls. All business logic lives in context modules. The LiveView is just a controller that handles events and delegates:
def mount(%{"id" => item_id}, _session, socket) do
scope = Scope.for_organisation_id(socket.assigns.organisation_id)
case Items.get_item(scope, item_id, preload: [:addons]) do
{:ok, item} ->
{:ok, assign(socket, item: item, form: to_form(Item.changeset(item, %{})))}
{:error, :not_found} ->
{:ok, socket |> put_flash(:error, "Not found") |> redirect(to: "/dashboard/rooms")}
end
end
This means the context functions are independently testable, reusable across LiveViews, and also available to the REST API we expose for third-party integrations:
# Same Items.list_available_items/4 powers both the LiveView AND the REST API
scope "/api/v3", FlygaWeb.Api.V3 do
get "/organisations/:organisation_id/items", ItemsController, :index
end
Authorization as a pipeline
LiveView’s on_mount hooks let you declare an authorization pipeline at the routing level:
live_session :dashboard_protected,
on_mount: [
FlygaWeb.Live.Hooks.RequireAuthentication,
FlygaWeb.Live.Hooks.RequireVerifiedEmail,
FlygaWeb.Live.Hooks.SidebarState
] do
live "/dashboard/bookings", BookingsListLive
live "/dashboard/bookings/new", BookingFormLive
# ...
end
Every dashboard route goes through auth → email verification → UI setup. You can’t forget to protect a route because the live_session enforces it. With Next.js, every page component needed its own auth wrapper, and forgetting one meant an unprotected page in production.
Trade-offs
This isn’t a “LiveView is always better” argument. There are real trade-offs:
- No offline support. LiveView requires a WebSocket connection. For a booking system where users are on reliable connections, this is fine. For a mobile-first app with spotty connectivity, it’d be a problem.
- Complex client-side interactions are harder. Drag-and-drop, rich text editing, canvas-based UIs — anything that needs fine-grained client-side state is more work in LiveView. We use JS hooks for a few things (date pickers, a Stripe payment element), and they work, but they’re not as ergonomic as writing a React component.
- The API boundary is implicit. With GraphQL, the contract between frontend and backend was explicit and documented. With LiveView, the “API” is the socket assigns and event handlers. This is fine for a single team, but if you had a separate frontend team consuming your API, you’d miss that boundary.
- SEO and static content. LiveView renders server-side on first load (so SEO is fine), but it’s not a great fit for a content-heavy marketing site. We use regular Phoenix controllers and templates for the public-facing pages.
One release, one deploy, one set of logs
Beyond the developer experience, the operational simplification is real. The old setup meant two deployment targets — the Phoenix API on Fly.io and the Next.js frontend on Vercel — with separate CI pipelines, separate health checks, separate log streams, and separate domains to manage. Debugging a production issue meant correlating logs across two systems and hoping the timestamps lined up.
Now it’s one Dockerfile, one release, one set of logs. When something breaks in production, the stack trace shows you the LiveView, the context function, and the query — all in one place.
Three codebases became one. Two deployment targets became one. Four layers of tests per feature became one. Features ship faster, bugs surface earlier, and the test suite runs in seconds instead of minutes.
Phoenix LiveView didn’t just replace the frontend framework — it eliminated the need for a separate frontend deployment, a separate test suite, and a separate mental model. That’s the promise of a full-stack platform that actually delivers. For a booking system where most interactions are “user clicks thing, server processes, page updates,” this was the right trade-off — but it’s a trade-off, not a free upgrade.