Rails with GraphQL

A Rails API and a GraphQL schema solve different parts of the same problem. Rails gives the application a durable home for routing, database work, configuration, and authentication. GraphQL Ruby gives clients a typed way to ask for the fields they need.
This note started as a small setup for a Rails API backed by PostgreSQL. What still holds up is the division of work: Rails owns the application, while GraphQL describes the client-facing operations. The commands below take one query all the way from a new app to an HTTP response.
Version note — August 30, 2026: This article was first published on January 27, 2025. The original bootstrap used Ruby
3.0.1and an unpinnedgem install rails. Keep that as historical context, not as a new-app recipe: Rails 8.0 and 8.1 require Ruby3.2.0or newer. The walkthrough below uses Rails8.1.3.1, Ruby3.4.10, and GraphQL Ruby~> 2.6. Check the Rails API-only guide and the GraphQL Ruby generator guide if you choose different versions.
Create an API-only Rails app
Pick an exact Ruby patch version that your deployment supports and record it in
.ruby-version. The version below is the one used for this dated example:
brew install rbenv ruby-build
rbenv install 3.4.10
rbenv shell 3.4.10Install and select the Rails version explicitly. Pinning the generator matters because Rails can change generated files between minor releases.
gem install rails --version 8.1.3.1
rails _8.1.3.1_ new library_api --api --database=postgresql
cd library_api
rbenv local 3.4.10The --api flag creates a smaller Rails stack. ApplicationController inherits
from ActionController::API, and Rails leaves out browser-oriented middleware,
views, helpers, and assets unless you add them back. That is a useful starting
point for a service whose public surface is JSON and GraphQL.
Before adding GraphQL, make sure the generated app can reach PostgreSQL:
bin/rails db:create
bin/rails serverStop the server after checking the app boots. The rest of the setup uses bin/rails
so every command runs against this application's bundle.
Add GraphQL Ruby
GraphQL Ruby is distributed as the graphql gem. Add the 2.6 minor series to the
bundle; Bundler will resolve and record the exact patch version in
Gemfile.lock.
bundle add graphql --version "~> 2.6.0"
bin/rails generate graphql:install --api
bundle installThe install generator creates the app/graphql tree, a schema, base type classes,
query and mutation roots, a controller, and the POST /graphql route. On an
API-only app, the --api option keeps the generated stack small instead of
adding the browser-based GraphiQL setup. You can still use a desktop GraphiQL
client or another GraphQL client during development.
At this point, inspect the generated files before writing fields:
app/graphql/types/query_type.rbapp/graphql/types/mutation_type.rbapp/graphql/library_api_schema.rbapp/controllers/graphql_controller.rbconfig/routes.rb
The generator is a useful starting point, not an architectural decision. Read the controller to see how request parameters become a schema execution, and read the schema to see where query and mutation roots are attached.
Define one query end to end
Create a model so the first field has a real database behind it:
bin/rails generate model Book title:string
bin/rails db:migrate
bin/rails runner 'Book.create!(title: "Practical GraphQL")'Now define the public shape of a book. A GraphQL type is an API contract; it is not a dump of every Active Record column.
# app/graphql/types/book_type.rb
module Types
class BookType < Types::BaseObject
description "A book in the library"
field :id, ID, null: false
field :title, String, null: false
end
endExpose a collection from the query root:
# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
field :books, [Types::BookType], null: false,
description: "Books ordered by title"
def books
::Book.order(:title)
end
end
endThe install generator has already connected the query root to the schema. For an
app named library_api, the relevant part of the generated schema looks like
this:
# app/graphql/library_api_schema.rb
class LibraryApiSchema < GraphQL::Schema
query Types::QueryType
endStart the server and send a query to the generated route:
bin/rails server
curl http://localhost:3000/graphql \
--request POST \
--header 'Content-Type: application/json' \
--data '{"query":"{ books { id title } }"}'The response has the shape requested by the client:
{
"data": {
"books": [{ "id": "1", "title": "Practical GraphQL" }]
}
}That small example shows the division of labor. Active Record loads the record; the GraphQL schema decides which fields are public and how the result is shaped. Adding another client view does not require another endpoint, but the schema still needs deliberate design.
Put application rules in the right layer
Rails should remain responsible for request authentication, database transactions, model validations, and shared business rules. The GraphQL controller can load the current user and pass it into the schema execution context. Fields and mutation classes can then use that context when they need an API-specific check.
For rules that should apply everywhere, keep the rule in application code rather
than hiding it in one resolver. For example, Book.visible_to(user) can be reused
by a REST endpoint, a background job, and a GraphQL field. GraphQL Ruby also has
visibility and authorization hooks when the schema itself must differ by viewer.
The same principle applies to mutations. A mutation named publishBook tells a
client what the application does. A mutation that simply mirrors an arbitrary
books.update call leaks persistence details and makes validation and authorization
harder to reason about.
Add limits before the schema grows
Nested GraphQL selections can be surprisingly expensive. A schema that is easy to query locally can become a database or downstream-service problem when a client asks for deep relationships or a large list. GraphQL Ruby provides depth and complexity limits, a built-in dataloader, and an execution timeout plugin:
# app/graphql/library_api_schema.rb
class LibraryApiSchema < GraphQL::Schema
query Types::QueryType
use GraphQL::Dataloader
max_depth 10
max_complexity 200
use GraphQL::Schema::Timeout, max_seconds: 2
endThose numbers are starting points, not universal safe values. Measure real
queries and tune them with the product requirements. Dataloader is useful when
many returned objects need related records because it can batch loads within one
query. The timeout plugin stops scheduling new fields after its limit; it does not
interrupt a resolver that is already blocked in a database or HTTP call. Give
those external clients their own operation-specific timeouts as well. The plugin
uses Ruby's Timeout API, so test it with your adapters rather than treating it as
a replacement for I/O-specific timeouts.
Error handling deserves the same attention. GraphQL validation errors appear in a
top-level errors array, and a response may contain both data and errors for a
partial result. For a mutation, domain failures such as an invalid transition are
often easier for clients to use as typed fields on the mutation payload. Reserve
unhandled exceptions for actual failures, and avoid turning a database exception
or stack trace into a public API message.
The part worth keeping small
The first version of a Rails and GraphQL service does not need a type for every table or a resolver abstraction for every field. Start with a few queries and mutations that describe product operations. Keep the generated controller visible, keep authorization close to the business rule, and add batching and limits when the shape of real queries justifies them.
That is the reason this setup remains useful years after the original note: Rails provides the application conventions, GraphQL Ruby provides the typed boundary, and the code between them stays inspectable.