Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 025 / 100

Building APIs with Rails: a hands-on guide for engineers

JSON APIs in Rails API mode.

Expona Admin6 min read

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.

ruby
rails new myapp --api

This 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:

ruby
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 } }
end

Call this from your controller:

ruby
def index
  @users = User.all
  render :index
end

Jbuilder 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:

ruby
class UserSerializer
  include Alba::Serializer

  attributes :id, :email, :created_at
  association :posts, serializer: PostSerializer
end

Then in your controller:

ruby
def index
  @users = User.all
  render json: UserSerializer.new(@users).serialize
end

Alba 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:

ruby
class UserBlueprint < Blueprinter::Base
  identifier :id
  fields :email, :created_at
  association :posts, blueprint: PostBlueprint
end

Then render:

ruby
def index
  @users = User.all
  render json: Blueprinter.render(@users, root: :users)
end

We 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:

ruby
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
end

For deletions, use head to return just a status with no body:

ruby
def destroy
  @user = User.find(params[:id])
  @user.destroy
  head :no_content
end

Rails 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:

ruby
render json: {
  error: "Validation failed",
  details: @user.errors.messages
}, status: :unprocessable_entity

Document 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:

ruby
namespace :v1 do
  resources :users
  resources :posts
end

namespace :v2 do
  resources :users
  resources :posts
end

This generates routes like /v1/users and /v2/users. Create corresponding controller directories:

ruby
app/controllers/v1/users_controller.rb
app/controllers/v2/users_controller.rb

Each version gets its own controller:

ruby
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
end

You 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:

ruby
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
end

This 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 --api to 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.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles