100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Programming

Building an API with Phoenix

A practical walkthrough of building a JSON API in Phoenix -- routing, controllers, Ecto contexts, JSON rendering, and testing.

PracticeIntermediate10 min readJul 10, 2026
Analogies

Setting Up a Phoenix API Project

A Phoenix API project typically starts with mix phx.new my_app --no-html --no-assets, which skips the HTML/asset pipeline entirely and scaffolds a project split into two top-level directories: lib/my_app, the pure business-logic layer with no knowledge of HTTP (Ecto schemas, contexts, business rules), and lib/my_app_web, the web layer containing the router, controllers, and JSON rendering modules. This separation means your core domain logic can be tested and reused independently of whether it's exposed over HTTP, a GraphQL endpoint, or a background job.

🏏

Cricket analogy: It's like a franchise separating its scouting and player-development department (the domain logic) from its matchday broadcast operations (the web layer) -- the same players and stats feed both, but they're organizationally independent.

Routing and Controllers

Phoenix's router defines pipelines -- named chains of plugs -- and then scopes routes through them; an API typically uses an :api pipeline that plugs Plug.Parsers for JSON bodies (skipping :fetch_session and CSRF protection, which are HTML-only concerns) and the resources "/posts", PostController, except: [:new, :edit] macro to generate the conventional RESTful routes (index, show, create, update, delete) in one line. Each controller action receives the connection struct (conn) and a params map, performs its work, and returns a transformed conn via functions like json/2 or render/3 -- controllers themselves should stay thin, delegating actual business logic to a context module rather than calling Repo functions directly.

🏏

Cricket analogy: A router pipeline is like a fixed pre-match routine every team follows -- toss, pitch inspection, warm-up -- before any match-specific play begins, just as the :api pipeline runs standard plugs before any controller action executes.

Contexts and Ecto for Data Access

Phoenix's convention is to wrap all database access behind a context module -- for example, a Blog context exposing functions like Blog.list_posts/0, Blog.get_post!/1, and Blog.create_post/1 -- so that controllers never call Repo.insert/1 or Repo.get/2 directly. Inside the context, Ecto.Changeset handles validation and casting: Post.changeset(%Post{}, attrs) declares which fields are required, applies validations like validate_length/3, and returns a changeset that's either valid (ready to insert) or carries structured errors, keeping validation logic centralized in the schema rather than scattered across controllers.

🏏

Cricket analogy: A context module is like a team's designated team manager who's the only one authorized to communicate with the board office -- players (controllers) never contact the board directly, they go through the manager, keeping communication consistent and auditable.

Rendering JSON Responses

In modern Phoenix (1.7+), a controller action like def show(conn, %{"id" => id}) fetches the resource through its context and calls render(conn, :show, post: post), which Phoenix dispatches to a matching PostJSON.show/1 function -- a plain module that explicitly builds the response map (e.g. %{data: %{id: post.id, title: post.title}}) rather than serializing the Ecto struct directly. For error cases, the idiomatic pattern is a FallbackController registered via action_fallback MyAppWeb.FallbackController, which pattern-matches on {:error, changeset} returned from a context function and renders a 422 with structured validation errors, keeping every controller action free of repetitive error-handling boilerplate.

🏏

Cricket analogy: A PostJSON module is like an official scorer who converts raw match data into the specific format published on the scoreboard, rather than fans reading straight off a player's private notebook -- you never expose the raw internal record directly.

elixir
# router.ex
pipeline :api do
  plug :accepts, ["json"]
end

scope "/api", MyAppWeb do
  pipe_through :api
  resources "/posts", PostController, except: [:new, :edit]
end

# post_controller.ex
defmodule MyAppWeb.PostController do
  use MyAppWeb, :controller
  alias MyApp.Blog

  action_fallback MyAppWeb.FallbackController

  def index(conn, _params) do
    render(conn, :index, posts: Blog.list_posts())
  end

  def create(conn, %{"post" => post_params}) do
    with {:ok, post} <- Blog.create_post(post_params) do
      conn
      |> put_status(:created)
      |> render(:show, post: post)
    end
  end
end

# post_json.ex
defmodule MyAppWeb.PostJSON do
  def index(%{posts: posts}), do: %{data: for(p <- posts, do: data(p))}
  def show(%{post: post}), do: %{data: data(post)}
  defp data(post), do: %{id: post.id, title: post.title, body: post.body}
end

For authentication, most Phoenix APIs use a token-based scheme (a Plug that validates a Bearer token, or the Guardian library for JWT) rather than session cookies -- sessions rely on :fetch_session, which the :api pipeline deliberately skips.

Never render an Ecto struct straight to JSON with something like Jason.encode!(post) -- unloaded associations show up as #Ecto.Association.NotLoaded<> structs that will raise a Protocol.UndefinedError, and even loaded structs may leak internal fields like password_hash. Always go through an explicit JSON view module that builds the response map field by field.

Testing the API

Phoenix API tests typically use ConnCase, which sets up a conn via build_conn() and provides helpers like json_response(conn, 200) to assert both the HTTP status code and parse the JSON body in one call; a typical test posts to /api/posts with valid params, asserts a 201 and that json_response(conn, 201)["data"]["title"] matches, then asserts a 422 with structured errors when required fields are missing. Test data is usually built with a factory library like ExMachina (insert(:post)) rather than hand-writing Repo.insert! calls in every test, keeping tests focused on behavior rather than setup boilerplate.

🏏

Cricket analogy: ConnCase is like a standardized net-practice setup that every batsman uses to test their technique under repeatable conditions, and json_response/2 is like a coach checking both footwork and shot outcome in a single glance rather than two separate reviews.

  • Scaffold API-only Phoenix projects with mix phx.new --no-html --no-assets to skip the HTML/asset pipeline entirely.
  • The :api router pipeline uses Plug.Parsers for JSON and skips session/CSRF plugs, which are HTML-specific concerns.
  • Keep controllers thin by delegating all business logic and database access to context modules (e.g. Blog.create_post/1).
  • Use Ecto.Changeset inside schemas to centralize validation and casting logic rather than scattering checks across controllers.
  • Render JSON through explicit view modules (e.g. PostJSON) rather than encoding Ecto structs directly, to control exactly what's exposed.
  • Use action_fallback with a FallbackController to centralize error-to-status-code mapping instead of repeating it in every action.
  • Test with ConnCase, json_response/2, and a factory library like ExMachina for concise, behavior-focused API tests.

Practice what you learned

Was this page helpful?

Topics covered

#Programming#ElixirStudyNotes#BuildingAnAPIWithPhoenix#Building#API#Phoenix#Setting#APIs#StudyNotes#SkillVeris