Author: Cursor AI (Powered by gemini-3-pro-preview) For: Steve Meisner Date: January 1, 2026
Welcome! You've just inherited a high-performance sports car (Elixir/Phoenix) after driving a reliable sedan (React/Node). It feels different, the steering is tighter, and the engine hums a weird tune. Don't worry, we're going to pop the hood.
You came from React + Node + Neon + Drizzle. You are now in Phoenix LiveView + Elixir + Postgres + Ecto.
In your old stack, you had a "Frontend" (React) and a "Backend" (Node/Express). They lived apart and talked via JSON. In Phoenix LiveView, the "Backend" renders the "Frontend" and keeps a persistent connection open. It's like having the server sit right next to the browser, whispering updates into its ear.
Why is this cool?
Let's look at the file structure. Think of your app as a professional kitchen.
mix.exs (The Shopping
List)This is your package.json. It defines your app name,
version, and most importantly, your dependencies
(deps).
{:phoenix, ...},
{:ecto, ...}, {:req, ...}.
mix deps.get (like
npm install).
lib/ (The Recipes)This is where all your code lives. It's split into two main folders:
lib/cuetube/
(The Business Logic)This is the "Back of House". It's where your data, rules, and logic live. It knows nothing about the web, HTML, or HTTP.
Accounts (User users),
Library (Playlists), YouTube (API
client).
accounts.ex. This is the public
interface for a feature. You don't query the User
table directly from a controller; you call
Accounts.get_user!(id).
lib/cuetube_web/ (The Front of House)
This is the "Dining Room". It handles web requests, renders HTML, and deals with the user.
controllers,
live (LiveViews), components (UI widgets),
router.ex.
priv/repo/migrations (The Blueprint Archive)
This is where your database structure is defined. Unlike Drizzle where you might define schemas and push, Ecto uses specific migration files to alter the DB step-by-step.
Elixir looks like Ruby but acts like... functional magic.
Everything is Immutable: You can't change a variable.
# React/JS
let count = 1;
count = 2; // Mutated!
# Elixir
count = 1
new_count = count + 1 # count is still 1
The Pipe Operator |>: This is
the best thing ever. It passes the result of the previous function as
the first argument of the next function.
# Nested (Hard to read)
serve(cook(chop(onion)))
# Pipe (Chef's kiss)
onion
|> chop()
|> cook()
|> serve()
Pattern Matching: The = sign isn't
just assignment; it's a match.
{:ok, user} = Accounts.create_user(params)
# If create_user returns {:error, ...}, this line CRASHES (or raises).
# It forces you to handle success/failure explicitly.
Open lib/cuetube_web/router.ex. This is the MaƮtre D'.
It greets every request and decides where it sits. We'll explore that
next.
In your old stack, you used Drizzle. Here, we use Ecto. Ecto is not just an ORM; it's a data mapping and validation toolkit. It separates "Data Representation" (Schemas) from "Database Interaction" (Repo).
lib/cuetube/accounts/user.ex
Open this file. This defines what a "User" looks like in Elixir struct form.
defmodule Cuetube.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :handle, :string
# ... other fields
has_many :playlists, Cuetube.Library.Playlist
timestamps()
end
schema "users": Maps this module to
the users table in Postgres.field: Defines the properties. Note
that :string covers varchar,
text, etc.
has_many: Defines the relationship. A
user has many playlists.Below the schema, you'll usually see a changeset
function. This is unique to Ecto. In many frameworks, you validate data
in the controller or a separate validator. In Ecto, validation
happens on the data structure itself.
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :handle, ...])
|> validate_required([:email])
|> unique_constraint(:email)
end
cast: "I accept these fields from the
outside world (forms/API) and map them to the struct." safely.validate_...: Runs logic checks
(length, format).unique_constraint: Checks the
database index to ensure uniqueness (no race conditions!).
Ecto splits the definition (User) from the action
(Repo). To save a user, you don't do
user.save(). You do Repo.insert(user).
However, in a Phoenix app, we wrap these raw Repo calls in a
Context. Look at
lib/cuetube/accounts.ex.
defmodule Cuetube.Accounts do
alias Cuetube.Repo
alias Cuetube.Accounts.User
def get_user!(id), do: Repo.get!(User, id)
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
end
It creates a Public API for your domain.
Accounts.create_user(params).User): Defines the shape of
data.Accounts): The friendly
manager that coordinates everything.The file lib/cuetube_web/router.ex is the central
nervous system of your web layer.
Web requests are just data. Phoenix treats a request (called
conn for connection) as a struct that gets passed through a
series of functions. Each function modifies it slightly. This is called
a Pipeline.
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :put_root_layout, html: {CuetubeWeb.Layouts, :root}
plug CuetubeWeb.UserAuth, :fetch_current_user
end
plug :accepts: "I only speak HTML
here."plug :fetch_session: "Go get the
cookie jar."plug :fetch_current_user: This is
custom! It looks at the session, finds the user_token,
looks up the user in the DB, and assigns it to
conn.assigns.current_user. Now every page knowns
who is logged in.
Scopes group routes together and apply pipelines to them.
scope "/", CuetubeWeb do
pipe_through :browser
live_session :public, on_mount: [{CuetubeWeb.UserAuth, :mount_current_user}] do
live "/", HomeLive
end
live_session :authenticated, on_mount: [{CuetubeWeb.UserAuth, :ensure_authenticated}] do
live "/dashboard", DashboardLive
end
end
live_session?This is crucial for LiveView. When you navigate between pages in a
live_session, the connection stays open.
It's super fast.
:mount_current_user just to check if they are
logged in, but we don't kick them out if they aren't.
:ensure_authenticated. If they aren't logged in, this plug
halts the request and redirects them to login.
You might see this weird syntax: ~p"/dashboard". This is
a sigil (like regex ~r/.../). It checks
your routes at compile time.
~p"/thumbnails/#{video_id}"
-> interpolates the ID and ensures the route exists.~p"/thumnails/#{id}" ->
COMPILER ERROR! (Typo detected).
In React, you often have broken links that you only find when you
click them. In Phoenix, if you change a route in router.ex
but forget to update a link, the app won't even compile.
You'll see some routes use get (standard HTTP) and some
use live (LiveView).
get "/auth/:provider", AuthController, :request:
This is a standard HTTP request. It hits the server, the server renders
HTML (or redirects), and the connection closes. Used for OAuth because
OAuth requires full page redirects.live "/dashboard", DashboardLive: This
loads a page, then establishes a WebSocket. It stays alive.AuthController.request -> redirects to
Google.AuthController.callback.Accounts, puts the user ID in the session, and redirects to
/dashboard.
/dashboard. The
live_session :authenticated sees the ID in the session and
lets them in.
This is the main event. LiveView allows you to write interactive UI (like React) entirely in Elixir.
Let's dissect
lib/cuetube_web/live/dashboard_live.ex.
A LiveView has a very specific lifecycle.
mount(params, session, socket)
This is the constructor. It runs twice:
def mount(_params, _session, socket) do
user_id = socket.assigns.current_user.id
playlists = Library.list_user_playlists(user_id)
{:ok, assign(socket, playlists: playlists)}
end
socket: This is your state (like
this.state or useState).
assign(socket, key: value): This sets
state. Here we fetch the user's playlists and store them in the
socket.render(assigns)This is your JSX. It takes the state (assigns) and
returns HTML. In Phoenix 1.7+, this is often inside the .ex
file using the ~H (Heex) sigil.
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<h1>My Playlists</h1>
<%= for playlist <- @playlists do %>
<.playlist_card playlist={playlist} />
<% end %>
</Layouts.app>
"""
end
<%= ... %>: Executes Elixir code
(loops, logic).@playlists: Accessing the state we set
in mount.<.playlist_card>: A Function
Component (like a React component).How do you handle a click?
<button phx-click="delete_playlist"
phx-value-id="{playlist.id}">Delete</button>
phx-click: The event name.phx-value-id: Passes data
(id) to the event.Back in the .ex file:
def handle_event("delete_playlist", %{"id" =>
id}, socket) do
Library.delete_playlist!(id)
# Update the list!
playlists = Library.list_user_playlists(socket.assigns.current_user.id)
{:noreply, assign(socket, playlists: playlists)}
end
handle_event runs on the server.socket with the new list.Notice what we didn't do?
DELETE /api/playlists/:id
endpoint.fetch() call in JS.We just changed the server state, and the UI updated.
Your app uses Tailwind CSS and HEEx (HTML + EEx).
The ~H syntax is strict. It forces you to write valid
HTML.
{ @variable }
(Phoenix 1.8+) or <%= @variable %> to output
data.<div class={@my_class}> allows dynamic values.
lib/cuetube_web/components/core_components.ex)This file is a goldmine. It contains reusable UI elements like
input, modal, table, and
button. It's generated by Phoenix but you own it. You can
change the Tailwind classes here to change the look of your
entire app.
attr :variant, :string, default: "primary" # Props
definition
attr :class, :any, default: nil
slot :inner_block, required: true # Children
def button(assigns) do
~H"""
<button class={[
"btn",
@variant == "primary" && "btn-primary",
@class
]}>
{render_slot(@inner_block)}
</button>
"""
end
attr: Defines what props the component
accepts. It's like TypeScript interfaces for your templates.slot: Where the children go.
<.button>Click Me</.button> -> "Click Me"
goes into inner_block.
In your LiveViews, you use them with a dot prefix:
<.button variant="secondary"
phx-click="cancel">
Cancel
</.button>
Your assets/css/app.scss imports DaisyUI (via the
config). This gives you classes like btn,
card, input. You don't need to write 50
utility classes for a button. class="btn btn-primary" does
the heavy lifting.
lib/cuetube_web/components/layouts/root.html.heex is the
skeleton (<html>, <head>,
<body>).
lib/cuetube_web/components/layouts/app.html.heex is the
wrapper for your main content (Navigation bar, Flash messages).
When DashboardLive renders, it's injected
inside app.html.heex, which is inside
root.html.heex.
You mentioned fetching data (YouTube). Let's see how that works in
lib/cuetube/youtube/client.ex.
ReqWe use a library called Req. It's the standard HTTP
client now. It's high-level and easy to use.
def get_playlist_details(playlist_id) do
req()
|> Req.get(url: "/playlists", params: [id: playlist_id, part:
"snippet"])
|> handle_response(...)
end
One of the coolest things in Elixir is handling JSON responses.
defp handle_response(result) do
case result do
# 1. Success! Pattern match the 200 OK and the body
{:ok, %{status: 200, body: body}} ->
{:ok, parse_body(body)}
# 2. API Error (404, 500)
{:ok, %{status: status}} ->
{:error, "YouTube said no: #{status}"}
# 3. Network Error (DNS failed, timeout)
{:error, reason} ->
{:error, "Internet broken: #{inspect(reason)}"}
end
end
This forces you to handle every scenario. You can't accidentally ignore a 404.
Notice Application.get_env(:cuetube, :youtube_api_key).
We never hardcode API keys. They live in
config/runtime.exs, which reads them from environment
variables (System.get_env("YOUTUBE_API_KEY")).
If fetching a playlist takes 5 seconds, you don't want to freeze the user's browser. In LiveView, you can do this:
This is powered by the BEAM's lightweight processes. You can spawn thousands of these tasks without sweating.
ThumbnailController)Sometimes you need to serve external assets (like YouTube thumbnails)
but you want to control caching or avoid mixed-content warnings. You
recently added
lib/cuetube_web/controllers/thumbnail_controller.ex.
def show(conn, %{"video_id" => video_id}) do
url = "https://i.ytimg.com/vi/#{video_id}/hqdefault.jpg"
case Req.get(url) do
{:ok, %{status: 200, body: body, headers: headers}} ->
content_type = Map.get(headers, "content-type") |> List.first()
conn
|> put_resp_content_type(content_type)
|> put_resp_header("cache-control", "public, max-age=604800")
|> send_resp(200, body)
_ -> send_resp(conn, 404, "Not Found")
end
end
LiveView is great for HTML, but Controllers are still king for
binary data (images, downloads, APIs). By proxying
through Req, we:
/thumbnails/xyz, not google.com.
cache-control to 1
week.