Skip to content
Development
Skill

/phoenix-json-api

Use when building JSON API endpoints — :api pipeline, phx.gen.json-style JSON views, FallbackController, pagination, Bearer tokens, rate limits, CORS. Not for HTML/LiveView controllers.

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill phoenix-json-api --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/phoenix-json-api

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when building JSON API endpoints — :api pipeline, phx.gen.json-style JSON views, FallbackController, pagination, Bearer tokens, rate limits, CORS. Not for HTML/LiveView controllers.

SKILL.md

phoenix-json-api.SKILL.md
name: phoenix-json-api
description: "Use when building JSON API endpoints — :api pipeline, phx.gen.json-style JSON views, FallbackController, pagination, Bearer tokens, rate limits, CORS. Not for HTML/LiveView controllers."

Phoenix JSON API

> Verified against Elixir 1.20, Phoenix 1.8, Ecto 3.14 — October 2026

RULES — Follow these with no exceptions

1. **Use the `:api` pipeline** — `plug :accepts, ["json"]`, no session, CSRF or browser headers; API clients authenticate with tokens 2. **Pass `conn.assigns.current_scope` as the first argument to every context call** — `Blog.get_post!(scope, id)` loads by id *and* owner; loading or deleting by a client-supplied id alone is an IDOR 3. **Follow `phx.gen.json`** — `action_fallback`, `render(conn, :show, post: post)` with a `PostJSON` module, `ChangesetJSON` for validation errors; `json/2` is fine for one-off bodies 4. **The `FallbackController` handles every error your contexts return, plus a catch-all 500** — an unmatched `{:error, _}` otherwise raises `FunctionClauseError`; never put `inspect(reason)` in the response body 5. **Never return unbounded collections** — parse `page`/`per_page` with `Integer.parse/1` and clamp (`1..100`); order by a unique tiebreaker (`desc: p.id`); use keyset pagination for large or fast-changing tables 6. **Bearer tokens are random, stored hashed, expire, and are revocable** — follow the generated `UserToken` pattern; never store or compare plaintext tokens 7. **Rate-limit login and token endpoints, and keep failures indistinguishable** — same error for unknown email and wrong password; the generated `get_user_by_email_and_password/2` already calls `Bcrypt.no_user_verify/0` for unknown users 8. **Preload every association the JSON view renders** — in the context function, not the view; `post.user` on an unloaded association is `%Ecto.Association.NotLoaded{}` (and the generated `Post` has no `belongs_to :user` until you add one) 9. **Version via URL prefix (`/api/v1`)** — no `as:` option needed; Phoenix 1.8 apps use verified routes, not router helpers

---

Router

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

pipeline :api_auth do
  plug MyAppWeb.Plugs.ApiAuth
end

scope "/api/v1", MyAppWeb.API.V1 do
  pipe_through :api

  post "/tokens", TokenController, :create
end

scope "/api/v1", MyAppWeb.API.V1 do
  pipe_through [:api, :api_auth]

  resources "/posts", PostController, except: [:new, :edit]
end

When v2 is needed, add `scope "/api/v2", MyAppWeb.API.V2` and put only the controllers that changed under `controllers/api/v2/`.

---

Controller (generator shape, scoped)

`phx.gen.json` with scopes generates exactly this shape. `get_post!/2` raises `Ecto.NoResultsError`, which `phoenix_ecto` turns into a 404 rendered by `ErrorJSON` — for another user's post as well as a missing one. Generated scoped controllers and their tests assume a pipeline that assigns `:current_scope`; see the `phoenix-scopes-and-generators` skill.

defmodule MyAppWeb.API.V1.PostController do
  use MyAppWeb, :controller

  alias MyApp.Blog
  alias MyApp.Blog.Post

  action_fallback MyAppWeb.FallbackController

  def index(conn, params) do
    page = parse_pos_int(params["page"], 1)
    per_page = params["per_page"] |> parse_pos_int(20) |> min(100)

    {posts, total} = Blog.paginate_posts(conn.assigns.current_scope, page: page, per_page: per_page)

    render(conn, :index, posts: posts, meta: %{page: page, per_page: per_page, total: total})
  end

  def create(conn, %{"post" => post_params}) do
    with {:ok, %Post{} = post} <- Blog.create_post(conn.assigns.current_scope, post_params) do
      conn
      |> put_status(:created)
      |> put_resp_header("location", ~p"/api/v1/posts/#{post}")
      |> render(:show, post: post)
    end
  end

  def show(conn, %{"id" => id}) do
    post = Blog.get_post!(conn.assigns.current_scope, id)
    render(conn, :show, post: post)
  end

  def update(conn, %{"id" => id, "post" => post_params}) do
    scope = conn.assigns.current_scope
    post = Blog.get_post!(scope, id)

    with {:ok, %Post{} = post} <- Blog.update_post(scope, post, post_params) do
      render(conn, :show, post: post)
    end
  end

  def delete(conn, %{"id" => id}) do
    scope = conn.assigns.current_scope
    post = Blog.get_post!(scope, id)

    with {:ok, %Post{}} <- Blog.delete_post(scope, post) do
      send_resp(conn, :no_content, "")
    end
  end

  # "abc", "0", "-5" and per_page[]=1 all fall back to the default instead of a 500
  defp parse_pos_int(value, default) when is_binary(value) do
    case Integer.parse(value) do
      {n, ""} when n > 0 -> n
      _ -> default
    end
  end

  defp parse_pos_int(_value, default), do: default
end

<!-- epg:bad -->

# IDOR: any authenticated user can read or delete any post by guessing ids
def delete(conn, %{"id" => id}) do
  post = Repo.get!(Post, id)
  {:ok, _} = Repo.delete(post)
  send_resp(conn, :no_content, "")
end

# 500 on "abc"; 0 or a negative value reaches LIMIT and Postgres errors
per_page = String.to_integer(params["per_page"])

---

JSON Views

# lib/my_app_web/controllers/api/v1/post_json.ex
defmodule MyAppWeb.API.V1.PostJSON do
  alias MyApp.Blog.Post

  def index(%{posts: posts, meta: meta}) do
    %{data: for(post <- posts, do: data(post)), meta: meta}
  end

  def show(%{post: post}) do
    %{data: data(post)}
  end

  defp data(%Post{} = post) do
    %{
      id: post.id,
      title: post.title,
      body: post.body,
      user_id: post.user_id,
      inserted_at: post.inserted_at
    }
  end
end

To render an association (say the author of a post in a shared feed), the schema needs it: the generated `Post` has `field :user_id, :id`, so replace that with `belongs_to :user, MyApp.Accounts.User` (the changeset's `put_change(:user_id, scope.user.id)` keeps working). Then preload `:user` in **every** context function whose result reaches the view (`get_post!/2`

Read more
Ships withelixir-phoenix-guide

· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.

Get the whole plugin

Other skills on elixir-phoenix-guide.