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 optionallyrelationships.
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:
-
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 -
Provide migration guide with before/after examples.
-
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 -
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:
- Response times (track per endpoint, per version)
- Error rates (track by status code, endpoint)
- Database queries (N+1 detection, slow queries)
- 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.