GuidesElixir
Phoenix GraphQL API with Absinthe and Ecto
Create a Phoenix GraphQL API with Absinthe, Ecto SQL, PostgreSQL, Phoenix Channels, and ExUnit using Better Fullstack.
Use this stack when clients should query a Phoenix backend through one GraphQL endpoint, with Ecto and PostgreSQL behind the resolvers and Phoenix Channels available for push updates.
npm create better-fullstack@latest my-graphql-app -- \ --ecosystem elixir \ --elixir-web-framework phoenix \ --elixir-orm ecto-sql \ --elixir-auth none \ --elixir-api absinthe \ --elixir-realtime channels \ --elixir-jobs none \ --elixir-testing ex_unit \ --elixir-deploy noneWhat this creates
- A Phoenix 1.7 application with
absintheandabsinthe_plug. - An Absinthe schema with an
itemobject and anitemsquery, forwarded at/graphql. - A resolver module that reads items through the
Catalogcontext and Ecto SQL. - A
UserSocketat/socketwith aroom:*channel that replies topingevents. - A JSON health endpoint at
/api/health, an ExUnitConnCase, and Credo.
Generated shape
my-graphql-app/
├── mix.exs
├── .env.example
├── config/
├── lib/
│ ├── my_graphql_app/
│ │ ├── catalog.ex
│ │ ├── catalog/item.ex
│ │ └── repo.ex
│ └── my_graphql_app_web/
│ ├── router.ex
│ ├── endpoint.ex
│ ├── channels/
│ │ ├── room_channel.ex
│ │ └── user_socket.ex
│ ├── controllers/
│ └── graphql/
│ ├── schema.ex
│ └── resolvers/catalog.ex
├── priv/repo/
│ ├── migrations/20260101000000_create_items.exs
│ └── seeds.exs
└── test/The scaffold also writes controllers/item_controller.ex, but with --elixir-api absinthe the router does not route it. The only /api route is /api/health; data access goes through /graphql.
Prerequisites
- Elixir 1.16 or newer with a matching Erlang/OTP release.
- A local PostgreSQL server reachable with the
POSTGRES_*values inconfig/dev.exs(defaults:postgres/postgresonlocalhost).
Run and verify
cd my-graphql-app
mix deps.get
mix ecto.setup
mix phx.servermix ecto.setup creates the database, runs the items migration, and inserts one seed item. Query it with any HTTP client:
curl -X POST http://localhost:4000/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ items { id name description } }"}'Run the tests with mix test. The generated suite covers /api/health; it does not query the GraphQL schema yet.
Representative snippets
The schema exposes one query and delegates to a resolver:
object :item do
field :id, non_null(:id)
field :name, non_null(:string)
field :description, :string
end
query do
field :items, non_null(list_of(non_null(:item))) do
resolve(&Resolvers.Catalog.items/3)
end
endThe resolver stays thin and calls the context, so GraphQL and any future REST controller share the same data functions:
def items(_parent, _args, _resolution) do
{:ok, Catalog.list_items()}
endThe router forwards GraphQL outside the :api pipeline:
forward "/graphql", Absinthe.Plug, schema: MyGraphqlAppWeb.GraphQL.SchemaExtending the schema
The schema has no mutations. MyGraphqlApp.Catalog.create_item/1 already validates input through Item.changeset/2, so a first mutation can call it through the resolver module. The schema aliases only MyGraphqlAppWeb.GraphQL.Resolvers, so add the mutation after the query block in graphql/schema.ex and point it at a resolver:
mutation do
field :create_item, :item do
arg :name, non_null(:string)
arg :description, :string
resolve(&Resolvers.Catalog.create_item/3)
end
endThen add the resolver to graphql/resolvers/catalog.ex, where Catalog already aliases MyGraphqlApp.Catalog. Absinthe cannot serialize an Ecto.Changeset, so the resolver turns changeset errors into messages:
def create_item(_parent, args, _resolution) do
case Catalog.create_item(args) do
{:ok, item} ->
{:ok, item}
{:error, changeset} ->
{:error,
changeset
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
Enum.reduce(opts, message, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
|> Enum.map(fn {field, messages} -> "#{field} #{Enum.join(messages, ", ")}" end)}
end
endA valid mutation { createItem(name: "Second") { id name } } returns the new item. A one-character name returns null for createItem and the error message name should be at least 2 character(s).
When to choose it
Choose Absinthe when several clients need different shapes of the same data and you want one typed contract. Choose --elixir-api rest when a few fixed JSON endpoints are enough; that path routes ItemController under /api/items.
Compatibility notes
--elixir-api absintherequires an Ecto database layer. The CLI rejects--elixir-orm nonewith Absinthe.--elixir-realtime channelsadds theUserSocketandRoomChannel.UserSocket.connect/3accepts every connection, so add token checks before exposing private topics.- The JSON library is Jason, selected through
--elixir-json jasonby default.
Deployment notes
This command sets --elixir-deploy none, so no Dockerfile or platform config is generated. config/runtime.exs still defines the production settings: SECRET_KEY_BASE and DATABASE_URL are required, and PHX_HOST, PORT, POOL_SIZE, and ECTO_SSL are optional. Pick --elixir-deploy docker, fly, gigalixir, or mix-release to generate deployment files.
Troubleshooting
| Symptom | Check |
|---|---|
/graphql returns an empty list | Run mix ecto.setup so the seed item exists. |
/api/items returns 404 | Expected with Absinthe; the REST controller is generated but not routed. |
| Channel joins succeed without a token | UserSocket.connect/3 returns {:ok, socket} for every client; add authentication. |
mix test cannot connect to PostgreSQL | The test alias creates and migrates the test database first, so the server must be running. |
Next steps
- Open the Stack Builder.
- Read the Elixir ecosystem docs.
- Compare with Phoenix REST API with Oban.