Building APIs with Rails: a hands-on guide for engineers
Most Rails applications start as monoliths serving HTML views, but the modern web demands clean, versioned JSON APIs that work seamlessly with mobile apps, SPAs, and third-party integrations. Building APIs with Rails requires a different mindset than traditional view-based development—you need to think about serialization, HTTP semantics, and API versioning from day one. In this guide, we'll walk through the practical patterns our teams use to ship production APIs that scale.
Why API mode matters
When you scaffold a traditional Rails app with rails new myapp, you get views, assets, mailers, and a full asset pipeline. For a JSON-only service, this bloat wastes time and introduces unnecessary complexity. Rails API mode strips away the cruft.
rails new myapp --apiThis single flag does three things: it omits ActionView, skips the asset pipeline, and removes middleware you won't need like cookies for form authenticity. Your ApplicationController inherits from ActionController::API instead of ActionController::Base, and your middleware stack is lean.
The resulting app is smaller, faster to boot, and forces you to make deliberate decisions about response formats. Your Gemfile won't pull in sass-rails or webpacker. Your routes are designed for API consumption. You're not tempted to render a view when you should return JSON.
That said, API mode isn't a hard rule. We've shipped hybrid apps where a subset of controllers render HTML and the rest serve JSON. Use --api when your primary use case is API-first; add views later if needed.
Serialization: choosing your format strategy
Raw model to JSON conversion is convenient but rigid. When a client needs different fields for a list endpoint versus a detail endpoint, or when an authenticated user sees more data than an anonymous user, you need serialization.
Rails ships with to_json and as_json, which work fine for simple cases. But in production, we've found that explicit serializers scale better. We typically choose between three libraries: Jbuilder, Alba, or Blueprinter.
Jbuilder: the Rails standard
Jbuilder comes with Rails and feels natural if you know ERB. Each endpoint gets its own template. For a users list:
json.array! @users do |user|
json.id user.id
json.email user.email
json.created_at user.created_at
json.posts user.posts.map { |p| { id: p.id, title: p.title } }
endCall this from your controller:
def index
@users = User.all
render :index
endJbuilder is familiar, versioning is easy (create app/views/users/index.json.jbuilder or app/views/v1/users/index.json.jbuilder), and it handles conditional fields well. The trade-off is that templates live outside your controller, and you don't get compile-time feedback if a method doesn't exist on your model.
Alba: fast and explicit
Alba is a newer serializer library that prioritizes performance and simplicity. Create a serializer class:
class UserSerializer
include Alba::Serializer
attributes :id, :email, :created_at
association :posts, serializer: PostSerializer
endThen in your controller:
def index
@users = User.all
render json: UserSerializer.new(@users).serialize
endAlba compiles serializers ahead of time, so it's faster than Jbuilder. It's also more testable—serializers are plain Ruby classes. The downside is less metaprogramming magic, which some teams prefer.
Blueprinter: control and composability
Blueprinter sits between Jbuilder and Alba in terms of power and convention. Define a blueprint:
class UserBlueprint < Blueprinter::Base
identifier :id
fields :email, :created_at
association :posts, blueprint: PostBlueprint
endThen render:
def index
@users = User.all
render json: Blueprinter.render(@users, root: :users)
endWe like Blueprinter for large codebases because blueprints are highly composable. You can render different subsets of fields by passing options, and it handles complex associations cleanly.
Our recommendation: start with Jbuilder if you're building a small API, move to Alba if performance matters and you have many endpoints, and reach for Blueprinter if you need field composition and your team values explicit structure.
HTTP status codes: be semantic
APIs that ignore HTTP status codes confuse clients. Always return the right status, not just 200 or 500.
Common statuses we use:
- 200 OK: successful read or update
- 201 Created: resource created successfully
- 204 No Content: successful deletion or action with no response body
- 400 Bad Request: client error in request structure
- 401 Unauthorized: missing or invalid authentication
- 403 Forbidden: authenticated but not allowed
- 404 Not Found: resource doesn't exist
- 422 Unprocessable Entity: validation error on POST/PATCH
- 500 Internal Server Error: server error
Here's a realistic create endpoint:
def create
@user = User.new(user_params)
if @user.save
render json: UserSerializer.new(@user).serialize, status: :created
else
render json: { errors: @user.errors.messages }, status: :unprocessable_entity
end
endFor deletions, use head to return just a status with no body:
def destroy
@user = User.find(params[:id])
@user.destroy
head :no_content
endRails symbol shortcuts like status: :created and head :no_content map to the right codes. Use them—they're more readable than status: 201 or status: 204.
When returning errors, be consistent. We recommend a standard error envelope:
render json: {
error: "Validation failed",
details: @user.errors.messages
}, status: :unprocessable_entityDocument your error responses in your API spec so clients know what to expect.
API versioning: namespace and isolate
APIs evolve. You'll break clients if you change endpoints without a migration path. Versioning lets you run multiple API versions in parallel.
The cleanest approach is to namespace routes and controllers by version:
namespace :v1 do
resources :users
resources :posts
end
namespace :v2 do
resources :users
resources :posts
endThis generates routes like /v1/users and /v2/users. Create corresponding controller directories:
app/controllers/v1/users_controller.rb
app/controllers/v2/users_controller.rbEach version gets its own controller:
module V1
class UsersController < ApplicationController
def index
@users = User.all
render json: UserSerializerV1.new(@users).serialize
end
end
end
module V2
class UsersController < ApplicationController
def index
@users = User.all
render json: UserSerializerV2.new(@users).serialize
end
end
endYou can also version via the Accept header, but URL-based versioning is simpler to debug and document. Make old versions explicit—don't hide them.
When deprecating a version, give clients time. At least two quarters of notice is standard. Return a deprecation header:
module V1
class ApplicationController < ::ApplicationController
after_action :set_deprecation_header
private
def set_deprecation_header
response.headers['Deprecation'] = 'true'
response.headers['Sunset'] = 1.year.from_now.httpdate
end
end
endThis tells clients that v1 will sunset and when.
Common mistakes
Returning 200 for everything: A POST that fails validation should return 422, not 200. A missing resource should return 404, not 200 with null. Clients rely on status codes to handle errors. Ignoring semantics forces them to parse JSON to know if a request succeeded.
Not validating input: Always validate params before saving. Use Rails validators and return clear errors. A vague 500 error tells your API consumer nothing.
Skipping versioning until it's too late: If you launch an API without versioning, adding it later is painful. Start with /v1 from day one, even if you think you won't need it. The cost is negligible.
Serializing too much data: Returning deeply nested associations on every endpoint tanks performance. Use sparse fieldsets or limit associations intentionally.
Mixing concerns in controllers: Keep authentication, authorization, business logic, and rendering separate. Use filters and service objects to stay organized as your API grows.
Key takeaways
- Use
rails new --apito skip views and asset pipeline; keep your API codebase lean and focused on JSON responses. - Choose a serializer (Jbuilder for simplicity, Alba for speed, Blueprinter for composability) and apply it consistently across endpoints.
- Return semantically correct HTTP status codes; treat them as part of your API contract, not afterthoughts.
- Version your API from day one by namespacing routes and controllers; include deprecation headers when retiring old versions.
- Validate input rigorously, return clear errors, and keep controllers thin by delegating business logic to models and service objects.






Comments
Loading comments…