Building a Scalable API with Ruby on Rails: Best Practices
[ Web Development ]

Building a Scalable API with Ruby on Rails: Best Practices

Master JSON:API standards, efficient serialization, and API versioning strategies in Rails. Learn production-ready patterns for scalable REST APIs that grow with your product.

→ featured
→ essential
→ timely
By Paul Badarau 12 min read
[ Article Content ]
Share this article:
P
Paul Badarau
Author

Building a Scalable API with Ruby on Rails: Best Practices

Meta Description: Master JSON:API standards, efficient serialization, and API versioning strategies in Rails. Learn production-ready patterns for scalable REST APIs that grow with your product.


The API That Worked Last Week Won't Work This Quarter

Three months ago, you shipped a simple API endpoint. It worked fine: a single query, a quick JSON response, life was good.

Now you have 50,000 daily active users. Mobile apps depend on your endpoints. Third-party integrations are consuming your data. You're getting Slack messages about response times and mysterious bugs where the same request returns different data shapes in different regions.

This is the moment every API engineer faces: the architecture decisions you made when you had 100 users are now your bottleneck.

The good news? Ruby on Rails makes it easy to build scalable APIs if you follow certain patterns. The bad news? It's equally easy to paint yourself into a corner with poor serialization choices, undocumented versioning, or query N+1s that compound as your data grows.

We've built and shipped dozens of Rails APIs—from internal tools to customer-facing platforms serving millions of requests per month. In this guide, we'll walk you through the exact patterns we use: JSON:API standards for consistency, efficient serialization for speed, and versioning strategies that let your API evolve without breaking clients.

By the end, you'll have a blueprint for building APIs that grow with your product, not against it.


Why JSON:API Standards Matter (Even If You Think They Don't)

When engineers hear "JSON:API standard," they often think: more boilerplate, more overhead, more complexity.

That's wrong. It's actually the opposite.

The JSON:API spec is a convention. Like Rails itself, it answers the question: "How should I structure my JSON responses?" Once you answer that question once, every subsequent endpoint follows the same pattern. No more debates. No more surprises for your API consumers.

Here's what JSON:API gives you:

1. Consistent Response Structure

Every JSON:API response has the same shape:

{
  "data": {
    "id": "1",
    "type": "articles",
    "attributes": {
      "title": "Building Scalable APIs",
      "body": "..."
    },
    "relationships": {
      "author": {
        "data": { "type": "users", "id": "42" }
      }
    }
  },
  "included": [
    {
      "id": "42",
      "type": "users",
      "attributes": {
        "name": "Sarah",
        "email": "sarah@example.com"
      }
    }
  ]
}

Your clients know:

  • Primary data is in data.
  • Related objects are in included.
  • Each object has id, type, attributes, and optionally relationships.

No surprises. No custom parsing per endpoint.

2. Automatic Inclusion of Related Objects

See the included array above? That's your related data, already fetched and included. A client can now render a full article with author, without making a second request.

Without this convention, your clients end up making N requests per page: one for articles, then one for each author. Your API gets hammered with requests. With JSON:API, you control inclusion via query parameters:

GET /api/v1/articles?include=author,comments.author

The client specifies what they need. Your API includes it. One request, full data, client controls the shape.

3. Error Standardization

JSON:API defines error responses:

{
  "errors": [
    {
      "status": "422",
      "code": "validation_error",
      "title": "Validation Failed",
      "detail": "Email has already been taken",
      "source": { "pointer": "/data/attributes/email" }
    }
  ]
}

Your client knows exactly where the error came from and what field failed. No parsing magic required.

4. Pagination and Filtering Conventions

GET /api/v1/articles?filter[status]=published&page[number]=2&page[size]=20

Standardized query parameters. Your client and server speak the same language about filtering and pagination.

This might seem like overkill for a small API. But consider the compounding benefit: every engineer on your team knows how the API works. Every external developer can read one line of docs and understand the pattern. Every mobile, web, and third-party client can use the same implementation.


Setting Up JSON:API with jsonapi-serializer

Let's build a production-ready API from the ground up.

Step 1: Install the Gem

# Gemfile
gem "jsonapi-serializer"

Then bundle:

bundle install

Step 2: Create Your Model and Migration

rails generate model Article title:string body:text published:boolean user:references
rails db:migrate

Update your model:

# app/models/article.rb
class Article < ApplicationRecord
  belongs_to :user
  has_many :comments, dependent: :destroy

  validates :title, presence: true
  validates :body, presence: true

  enum :status, { draft: 0, published: 1, archived: 2 }

  scope :published, -> { where(status: :published) }
  scope :recent, -> { order(created_at: :desc) }
end

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :article
  belongs_to :user

  validates :body, presence: true
end

# app/models/user.rb
class User < ApplicationRecord
  has_many :articles, dependent: :destroy
  has_many :comments, dependent: :destroy
end

Step 3: Create Serializers

Serializers transform your models into JSON:API-compliant responses:

# app/serializers/article_serializer.rb
class ArticleSerializer
  include JSONAPI::Serializer

  set_type :articles
  attributes :title, :body, :status, :created_at, :updated_at

  has_one :user
  has_many :comments
end

# app/serializers/user_serializer.rb
class UserSerializer
  include JSONAPI::Serializer

  set_type :users
  attributes :name, :email
end

# app/serializers/comment_serializer.rb
class CommentSerializer
  include JSONAPI::Serializer

  set_type :comments
  attributes :body, :created_at

  has_one :user
end

Step 4: Build Your Controllers

# app/controllers/api/v1/articles_controller.rb
class Api::V1::ArticlesController < ApplicationController
  before_action :set_article, only: [:show, :update, :destroy]

  # GET /api/v1/articles?include=author,comments.author
  def index
    articles = Article.published.recent
    # Parse JSON:API include parameter to avoid N+1
    articles = articles.includes(parse_includes)
    render json: ArticleSerializer.new(articles, {
      include: params[:include]
    }).serializable_hash.to_json
  end

  # GET /api/v1/articles/:id?include=author
  def show
    article = Article.find(params[:id]).includes(parse_includes)
    render json: ArticleSerializer.new(article, {
      include: params[:include]
    }).serializable_hash.to_json
  end

  # POST /api/v1/articles
  def create
    article = Article.new(article_params)
    if article.save
      render json: ArticleSerializer.new(article).serializable_hash.to_json,
             status: :created
    else
      render json: error_response(article.errors), status: :unprocessable_entity
    end
  end

  # PATCH /api/v1/articles/:id
  def update
    if @article.update(article_params)
      render json: ArticleSerializer.new(@article).serializable_hash.to_json
    else
      render json: error_response(@article.errors), status: :unprocessable_entity
    end
  end

  # DELETE /api/v1/articles/:id
  def destroy
    @article.destroy
    head :no_content
  end

  private

  def set_article
    @article = Article.find(params[:id])
  end

  def article_params
    params.require(:data).permit(:title, :body, :status)
  end

  def parse_includes
    return [] unless params[:include]
    params[:include].split(',').map { |i| i.underscore.to_sym }
  end

  def error_response(errors)
    {
      errors: errors.full_messages.map { |msg| {
        status: "422",
        title: "Validation Error",
        detail: msg
      } }
    }
  end
end

That's it. Your API now returns JSON:API-compliant responses with related data, errors, and proper conventions.


Serialization Performance: When JSON:API Gets Expensive

JSON:API is powerful, but it can become a performance bottleneck if you're not careful. Here's the problem:

# Serializer
has_many :comments

# This triggers N+1!
ArticleSerializer.new(articles, include: "comments")

# Rails loads articles (1 query)
# Then for each article, loads comments (N queries)
# Total: 1 + N queries

The solution? Always use includes before serializing.

# Good: eager load
articles = Article.includes(:comments).recent
ArticleSerializer.new(articles, include: "comments")

# Bad: lazy load
articles = Article.recent
ArticleSerializer.new(articles, include: "comments")  # N+1!

But managing includes manually gets tedious. Here's a helper:

# app/controllers/api/v1/articles_controller.rb
def index
  articles = Article.recent
  articles = with_includes(articles)
  render json: ArticleSerializer.new(articles, {
    include: params[:include]
  }).serializable_hash.to_json
end

private

def with_includes(relation)
  includes = params[:include]&.split(',')&.map { |i| i.underscore.to_sym } || []
  return relation if includes.empty?

  # Map include names to actual associations
  includes_map = {
    user: :user,
    comments: { comments: :user },
    author: :user
  }

  associations_to_load = includes.map { |i| includes_map[i] }.compact
  relation.includes(*associations_to_load)
end

Now your controller automatically loads related data based on what the client requested.

For larger data sets, consider caching:

# app/controllers/api/v1/articles_controller.rb
def show
  cache_key = "articles/#{params[:id]}/#{params[:include]}"
  render json: Rails.cache.fetch(cache_key, expires_in: 1.hour) do
    article = Article.find(params[:id]).includes(parse_includes)
    ArticleSerializer.new(article, include: params[:include]).serializable_hash.to_json
  end
end

And invalidate cache when the data changes:

# app/models/article.rb
after_commit :invalidate_cache

def invalidate_cache
  Rails.cache.delete_matched("articles/#{id}/*")
end

API Versioning: Strategies That Actually Scale

Your API is live. You have 100 mobile app installations. Now you need to add a field to the response. Can you just add it?

Maybe. But what if it breaks the mobile app? What if you need to change the response structure? What if you need to deprecate an endpoint?

This is why versioning exists. But done wrong, it becomes a maintenance nightmare.

Strategy 1: URL Path Versioning (Simplest, Recommended)

GET /api/v1/articles
GET /api/v2/articles

In config/routes.rb:

namespace :api do
  namespace :v1 do
    resources :articles, only: [:index, :show, :create, :update, :destroy]
  end

  namespace :v2 do
    resources :articles, only: [:index, :show, :create, :update, :destroy]
  end
end

Create separate controllers:

# app/controllers/api/v1/articles_controller.rb
class Api::V1::ArticlesController < ApplicationController
  # Original implementation
end

# app/controllers/api/v2/articles_controller.rb
class Api::V2::ArticlesController < ApplicationController
  # New implementation with additional fields
end

Pros:

  • Clear, explicit versioning.
  • Easy to maintain multiple versions simultaneously.
  • Clients know exactly which version they're calling.

Cons:

  • Code duplication (you need two controllers).
  • Takes up URL space.

When to use: When you have breaking changes or fundamentally different response structures.

Strategy 2: Accept Header Versioning (Elegant, Complex)

GET /api/articles
Accept: application/vnd.elaris.v2+json

In config/routes.rb:

namespace :api do
  resources :articles, only: [:index, :show]
end

Create a single controller that responds differently based on the header:

# app/controllers/api/articles_controller.rb
class Api::ArticlesController < ApplicationController
  def index
    articles = Article.recent
    serializer = case api_version
                 when 2
                   ArticleSerializerV2
                 else
                   ArticleSerializer
                 end

    render json: serializer.new(articles).serializable_hash.to_json
  end

  private

  def api_version
    request.headers["Accept"]&.match(/v(\d+)/)&.captures&.first&.to_i || 1
  end
end

Pros:

  • Cleaner URLs.
  • Single code path with conditional logic.

Cons:

  • Less explicit (clients might not know what version they're using).
  • Harder to debug in the browser (you can't just paste a URL).

When to use: When you have minor changes or want to reduce URL clutter.

Strategy 3: Graceful Deprecation (Best for Long-Term Maintenance)

Don't version everything. Version only when you have to.

Instead, use feature flags and conditional fields:

# app/serializers/article_serializer.rb
class ArticleSerializer
  include JSONAPI::Serializer

  set_type :articles
  attributes :title, :body, :status

  # Only include new_field if client requests it or API version >= 2
  attribute :author_summary, if: Proc.new { |record, params|
    params[:include_author_summary] == true || api_version(params) >= 2
  }

  private

  def api_version(params)
    params[:api_version]&.to_i || 1
  end
end

Pros:

  • Backward compatible.
  • No need to maintain multiple code paths.
  • Clients can opt-in to new fields.

Cons:

  • Less explicit; can be confusing.
  • Serializer logic becomes conditional.

When to use: When you're adding fields non-breaking changes, and want to minimize maintenance burden.

How to Handle Breaking Changes

Sometimes you need a breaking change. Here's the process:

  1. Announce deprecation (3 months in advance):

    Deprecation-Notice: v1 API endpoints will sunset on March 1, 2025
    Deprecation-Link: /api/migration-guide/v1-to-v2
    
  2. Provide migration guide with before/after examples.

  3. Log clients using deprecated version:

    # app/controllers/api/v1/articles_controller.rb
    class Api::V1::ArticlesController < ApplicationController
      before_action :log_deprecation
    
      private
    
      def log_deprecation
        Rails.logger.warn("API v1 call from #{request.remote_ip}")
      end
    end
    
  4. Sunset the old version on the announced date.


Production Patterns: Error Handling, Rate Limiting, and Authentication

Error Handling That Clients Can Parse

# app/controllers/api/application_controller.rb
class Api::ApplicationController < ApplicationController
  rescue_from ActiveRecord::RecordNotFound do |e|
    render json: {
      errors: [{
        status: 404,
        title: "Not Found",
        detail: "The requested resource could not be found"
      }]
    }, status: :not_found
  end

  rescue_from ActiveRecord::RecordInvalid do |e|
    render json: {
      errors: e.record.errors.full_messages.map { |msg| {
        status: 422,
        title: "Validation Error",
        detail: msg
      } }
    }, status: :unprocessable_entity
  end

  rescue_from StandardError do |e|
    Rails.logger.error("Unexpected error: #{e.message}")
    render json: {
      errors: [{
        status: 500,
        title: "Internal Server Error",
        detail: Rails.env.production? ? "An error occurred" : e.message
      }]
    }, status: :internal_server_error
  end
end

Rate Limiting

Use the rack-attack gem:

# Gemfile
gem "rack-attack"

# config/rack_attack.rb
class Rack::Attack
  # Throttle requests to 60 per minute per IP
  throttle("api/ip", limit: 60, period: 60.seconds) do |req|
    req.ip if req.path.start_with?("/api")
  end

  # Throttle authenticated users to 1000 per hour
  throttle("api/user", limit: 1000, period: 1.hour) do |req|
    req.user_id if req.path.start_with?("/api") && req.user_id
  end
end

Authentication with JWT

# Gemfile
gem "jwt"

# app/controllers/api/application_controller.rb
class Api::ApplicationController < ApplicationController
  skip_forgery_protection
  before_action :authenticate_request

  private

  def authenticate_request
    header = request.headers["Authorization"]
    return render_unauthorized unless header

    token = header.split(" ").last
    decoded = JWT.decode(token, Rails.application.secrets.secret_key_base, true, { algorithm: "HS256" })
    @current_user = User.find(decoded[0]["user_id"])
  rescue JWT::DecodeError, ActiveRecord::RecordNotFound
    render_unauthorized
  end

  def render_unauthorized
    render json: {
      errors: [{
        status: 401,
        title: "Unauthorized",
        detail: "Invalid or missing authentication token"
      }]
    }, status: :unauthorized
  end
end

Monitoring and Observability

An API is only as good as your visibility into it. Track:

  1. Response times (track per endpoint, per version)
  2. Error rates (track by status code, endpoint)
  3. Database queries (N+1 detection, slow queries)
  4. Cache hit rates

Use Sentry for errors and New Relic or DataDog for performance:

# config/initializers/error_tracking.rb
Sentry.init do |config|
  config.dsn = ENV["SENTRY_DSN"]
  config.environment = Rails.env
  config.traces_sample_rate = 0.1  # Sample 10% of requests
end

Bringing It All Together: A Checklist for Scalable Rails APIs

Before you ship your API to production, ensure:


Next Steps

Start with JSON:API conventions this week. Add serializers to your endpoints. Measure query performance. Then decide on versioning.

The best API isn't the most feature-rich—it's the one that's simple to use, fast to call, and easy to maintain as it grows.

Your future self (and your API clients) will thank you.


Related Reading

[ Let's Build Together ]

Ready to transform your
business with software?

From strategy to implementation, we craft digital products that drive real business outcomes. Let's discuss your vision.

Related Topics:
Ruby on Rails API JSON:API SaaS backend 2025