JSON in Ruby on Rails: APIs, Serializers, and Responses
Last updated:
Ruby on Rails has supported JSON APIs since Rails 3, and the tooling has matured into a complete stack: render json: for simple responses, --api mode for lean applications, strong parameters for safe input handling, ActiveModel Serializers and jsonapi-serializer for reusable output shapes, and Jbuilder for complex nested responses. This guide covers each tool, when to reach for it, and the patterns that keep Rails JSON APIs maintainable at scale.
render json: Basics
render json: is the simplest way to return JSON from a Rails controller. It calls to_json on whatever object you pass, sets Content-Type: application/json, and returns a 200 status by default. Use only:, except:, and include: to shape the output without a separate serializer class.
class ProductsController < ApplicationController
# GET /products/:id
def show
@product = Product.find(params[:id])
# Simple: serialize all attributes
render json: @product
# Whitelist specific fields
render json: @product, only: [:id, :name, :price, :created_at]
# Exclude internal fields
render json: @product, except: [:internal_code, :cost_price]
# Include an association
render json: @product, include: :category
# Custom status
render json: @product, status: :ok # 200
render json: @product, status: :created # 201
end
# POST /products
def create
@product = Product.new(product_params)
if @product.save
render json: @product, status: :created # 201
else
render json: { errors: @product.errors }, status: :unprocessable_entity # 422
end
end
# DELETE /products/:id
def destroy
@product = Product.find(params[:id])
@product.destroy
head :no_content # 204 — no body
end
endThe only: / except: options are convenient for one-off endpoints but become hard to maintain as response shapes grow. Once more than 2–3 controllers share the same model, switch to a serializer class.
Rails --api Mode
rails new myapi --api creates an application where ApplicationController inherits from ActionController::API instead of ActionController::Base. This removes the view layer, cookie handling, browser session middleware, and asset pipeline — roughly 25% fewer middleware modules than a full Rails app.
# Create a new API-only Rails app
rails new myapi --api
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
# ActionController::API includes:
# - ActionController::Rendering
# - ActionController::Redirecting
# - ActionController::Params
# - ActionController::ConditionalGet
# - ActionController::BasicImplicitRender
# - ActionController::StrongParameters
# - ActionController::DataStreaming
# - ActionController::Rescue
# It does NOT include:
# - ActionController::Cookies
# - ActionController::Session
# - ActionController::Flash
# - ActionView integration
end
# To add a module back (e.g. cookie support for Devise)
class ApplicationController < ActionController::API
include ActionController::Cookies
end| Feature | Full Rails App | --api Mode |
|---|---|---|
| Views / ERB templates | Yes | No |
| Asset pipeline / Sprockets | Yes | No |
| Cookie / session middleware | Yes | No (addable) |
| Routing, Active Record, Strong Params | Yes | Yes |
| ActionController::API base | No | Yes |
| Approximate middleware count | ~23 modules | ~11 modules |
Strong Parameters for JSON Input
Strong parameters protect against mass-assignment vulnerabilities by requiring you to explicitly whitelist which fields the controller will accept from the request body. This applies equally to JSON requests and form submissions — Rails merges parsed JSON into the params hash automatically.
class ProductsController < ApplicationController
def create
@product = Product.new(product_params)
if @product.save
render json: @product, status: :created
else
render json: { errors: @product.errors.full_messages }, status: :unprocessable_entity
end
end
private
def product_params
# .require raises ActionController::ParameterMissing if :product key is absent
# .permit whitelists allowed scalar fields
params.require(:product).permit(
:name,
:price,
:category_id,
:description,
:is_active
)
end
end
# Nested JSON objects: permit with hash syntax
def address_params
params.require(:address).permit(
:street,
:city,
:zip,
coordinates: [:lat, :lng] # nested hash
)
end
# Arrays of scalars: use array syntax
def tag_params
params.require(:product).permit(tag_ids: []) # array of IDs
end
# Incoming JSON body example (curl):
# curl -X POST https://api.example.com/products \
# -H "Content-Type: application/json" \
# -d '{"product": {"name": "Widget", "price": 9.99, "category_id": 3}}'
# Rails merges this into params[:product]ActiveModel Serializers
ActiveModel Serializers (AMS) moves JSON shape definition out of the controller into a dedicated class. When Rails finds a matching serializer class, it uses it automatically — no change needed in the controller's render json: call.
# Gemfile
gem 'active_model_serializers', '~> 0.10'
# Generate a serializer
# rails generate serializer Product
# app/serializers/product_serializer.rb
class ProductSerializer < ActiveModel::Serializer
attributes :id, :name, :price, :created_at
# Conditionally include a field
attribute :internal_code, if: -> { scope&.admin? }
# Association — uses CategorySerializer automatically
belongs_to :category
has_many :tags
# Computed attribute
attribute :price_usd do
object.price.to_f.round(2)
end
end
# app/serializers/category_serializer.rb
class CategorySerializer < ActiveModel::Serializer
attributes :id, :name
end
# Controller — no change needed
class ProductsController < ApplicationController
def show
@product = Product.includes(:category, :tags).find(params[:id])
render json: @product
# Rails finds ProductSerializer automatically — outputs:
# {"id":1,"name":"Widget","price":"9.99","created_at":"...","category":{...},"tags":[...]}
end
end
# AMS adapter options (config/initializers/active_model_serializers.rb)
ActiveModelSerializers.config.adapter = :attributes # flat hash (default)
# ActiveModelSerializers.config.adapter = :json # adds root key
# ActiveModelSerializers.config.adapter = :json_api # JSON:API 1.0jsonapi-serializer (fast-jsonapi)
jsonapi-serializer (originally Netflix's fast-jsonapi) is a high-throughput serializer gem for JSON:API 1.0 output. Benchmarks show 25x–135x faster serialization than AMS on large collections because it avoids per-record Ruby object instantiation and supports fragment-level caching.
# Gemfile
gem 'jsonapi-serializer'
# app/serializers/product_serializer.rb
class ProductSerializer
include JSONAPI::Serializer
set_type :product
set_id :id
attributes :name, :price, :created_at
# Computed attribute
attribute :price_usd do |object|
object.price.to_f.round(2)
end
# Relationships
belongs_to :category
has_many :tags
# Cache key for fragment caching (major perf win on large lists)
cache_options store: Rails.cache, namespace: 'jsonapi', expires_in: 5.minutes
end
# Serialize a single record
ProductSerializer.new(@product).serializable_hash
# => { data: { id: "1", type: "product", attributes: { name: "Widget", ... } } }
# Serialize a collection
ProductSerializer.new(@products, is_collection: true).serializable_hash
# Include relationships in output
ProductSerializer.new(@product, include: [:category, :tags]).serializable_hash
# Serialize with meta
ProductSerializer.new(
@products,
is_collection: true,
meta: { total: @products.count, page: params[:page] }
).serializable_hash
# Controller
class ProductsController < ApplicationController
def index
@products = Product.includes(:category, :tags).page(params[:page]).per(20)
render json: ProductSerializer.new(@products, is_collection: true).serializable_hash
end
endJbuilder Templates
Jbuilder is included in Rails by default and lets you build JSON responses using Ruby code in view templates (.json.jbuilder files). It is the right tool when response shape is complex: deep nesting, conditional fields, or partial reuse across endpoints.
# app/views/products/show.json.jbuilder
json.id @product.id
json.name @product.name
json.price @product.price.to_f
# Conditional field
json.internal_code @product.internal_code if current_user&.admin?
# Nested object
json.category do
json.id @product.category.id
json.name @product.category.name
end
# Array of objects
json.tags @product.tags do |tag|
json.id tag.id
json.name tag.name
end
# app/views/products/index.json.jbuilder
json.data do
json.array! @products do |product|
json.partial! 'products/product', product: product
end
end
json.meta do
json.total @products.total_count
json.page params[:page].to_i
end
# app/views/products/_product.json.jbuilder (partial)
json.id product.id
json.name product.name
json.price product.price.to_f
json.links do
json.self product_url(product)
end
# Controller — responds_to block selects the template
class ProductsController < ApplicationController
def show
@product = Product.includes(:category, :tags).find(params[:id])
respond_to do |format|
format.json # renders show.json.jbuilder automatically
format.html # renders show.html.erb (full Rails apps only)
end
end
endJbuilder templates are slower than serializer gems on large collections — avoid them for list endpoints returning more than ~50 records. Prefer Jbuilder for show endpoints with complex conditional logic that would be verbose in a serializer class.
Error Responses and Rescue
Use rescue_from in ApplicationController to intercept exception classes and return consistent JSON error envelopes. Always include a machine-readable error code alongside the human-readable message so clients can branch on error type without parsing strings.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable
rescue_from ActionController::ParameterMissing, with: :render_bad_request
private
def render_not_found(e)
render json: {
error: 'not_found',
message: e.message
}, status: :not_found # 404
end
def render_unprocessable(e)
render json: {
error: 'unprocessable_entity',
message: e.message,
details: e.record.errors.full_messages
}, status: :unprocessable_entity # 422
end
def render_bad_request(e)
render json: {
error: 'bad_request',
message: "Missing required parameter: #{e.param}"
}, status: :bad_request # 400
end
end
# Common HTTP status helpers in Rails
# :ok => 200
# :created => 201
# :no_content => 204
# :bad_request => 400
# :unauthorized => 401
# :forbidden => 403
# :not_found => 404
# :unprocessable_entity => 422
# :internal_server_error => 500
# Standardized error envelope:
# {
# "error": "not_found",
# "message": "Couldn't find Product with id=999",
# "details": [] // optional array of field-level errors
# }
# Validation errors from model save
def create
@product = Product.new(product_params)
if @product.save
render json: @product, status: :created
else
render json: {
error: 'validation_failed',
message: 'Validation failed',
details: @product.errors.as_json # { name: ['can\'t be blank'], price: ['is not a number'] }
}, status: :unprocessable_entity
end
endDefinitions
- serializer
- A Ruby class that transforms a model object into a JSON-serializable hash, defining exactly which attributes and associations appear in the output. Examples include
ActiveModel::SerializerandJSONAPI::Serializer. Serializers decouple the database schema from the API contract and centralize output shape in one place. - strong parameters
- An
ActionController::Parametersmechanism that requires explicit whitelisting of request fields before they can be passed to model methods. Callingparams.require(:product).permit(:name, :price)returns only the permitted keys, preventing mass-assignment attacks where an attacker could update fields likeadminorrolefrom a JSON body. - render json:
- A Rails controller method that serializes an object to JSON (via
to_json), sets theContent-Type: application/jsonresponse header, and returns the HTTP response. Acceptsonly:,except:,include:, andstatus:options to control the output shape and HTTP status code. - Jbuilder
- A Ruby DSL gem included with Rails that lets you build JSON responses in
.json.jbuilderview template files using Ruby code. Suited for complex nested structures, conditional fields, and partial reuse across multiple endpoints. Slower than serializer gems on large collections. - --api mode
- A Rails generator flag (
rails new myapp --api) that creates an application withApplicationControllerinheriting fromActionController::APIinstead ofActionController::Base. This strips the view layer, asset pipeline, cookies, and browser session middleware — producing a lighter-weight app suited for JSON-only APIs consumed by mobile or SPA clients.
FAQ
How do I return JSON from a Rails controller?
Use render json: inside any controller action. render json: @product calls to_json and sets Content-Type: application/json automatically. Pass only: [:id, :name] to whitelist fields, except: [:internal_code] to exclude fields, or include: :category to serialize an association. Combine with status: :created (201) or status: :not_found (404) to set the HTTP status code. For a 204 No Content response (e.g. after a DELETE), use head :no_content instead.
What is rails new --api mode?
Running rails new myapi --api generates a stripped-down Rails application where ApplicationController inherits from ActionController::API instead of ActionController::Base. This removes the view layer, asset pipeline, cookie handling, and browser session middleware — roughly 25% fewer middleware modules, resulting in faster boot times and lower memory usage. All routing, Active Record, and strong parameter functionality is preserved. Use --api as the default starting point for any Rails app that serves only JSON to mobile apps, SPAs, or other services.
What are strong parameters in Rails and why do they matter for JSON APIs?
Strong parameters require you to explicitly whitelist which fields from the request body are allowed for mass assignment. Without them, an attacker could send {"admin: true"} in a JSON body and update fields you never intended to expose. Call params.require(:product).permit(:name, :price, :category_id) to define the allowed set. Rails merges parsed JSON bodies into params automatically when the request has Content-Type: application/json, so the same params.require().permit() pattern applies to both JSON and form-encoded requests.
What is ActiveModel Serializers (AMS)?
ActiveModel Serializers is a gem that defines JSON output shape in a dedicated serializer class. Each serializer inherits from ActiveModel::Serializer and declares attributes and associations. When you call render json: @product, Rails finds ProductSerializer automatically and uses it. AMS supports three adapters: :attributes (flat hash), :json (adds a root key), and :json_api (JSON:API 1.0 compliant). The gem has slowed in active maintenance; jsonapi-serializer is now the recommended successor for performance-sensitive or JSON:API-compliant projects.
What is jsonapi-serializer and how does it differ from AMS?
jsonapi-serializer (originally Netflix's fast-jsonapi) outputs JSON:API 1.0-compliant responses and is 25x–135x faster than AMS on large collections because it avoids instantiating Ruby objects per record and supports fragment-level caching via cache_options. Define serializers with include JSONAPI::Serializer, declare attributes and associations, then call ProductSerializer.new(@product).serializable_hash in the controller. Use this gem when you need JSON:API compliance, are returning collections of hundreds or thousands of records, or need cache-level performance optimization.
When should I use Jbuilder vs render json: in Rails?
Use render json: for simple, flat responses where the JSON structure maps closely to a model. Use Jbuilder (.json.jbuilder templates) when the response is complex: deep nesting, conditional fields based on user roles, partial reuse across multiple endpoints, or building JSON from multiple unrelated objects. Jbuilder is included in Rails by default. Its main trade-off is performance — it is slower than serializer gems on list endpoints returning many records, so prefer a serializer for collection endpoints and Jbuilder for complex single-resource show endpoints.
How does Rails handle incoming JSON request bodies?
Rails parses application/json request bodies automatically via ActionDispatch::Request::Utils. The parsed fields are merged into the params hash so params[:name] works the same whether the data came from JSON or form encoding. Access the raw unparsed body string with request.raw_post or request.body.read. Always set Content-Type: application/json on the request from clients — without it Rails may treat the body as a plain string and not parse it into params.
How do I rescue exceptions and return structured JSON error responses in Rails?
Use rescue_from in ApplicationController to catch exception classes globally and render JSON error envelopes. For example, rescue_from ActiveRecord::RecordNotFound, with: :render_not_found maps to a handler method that calls render json: { error: 'not_found', message: e.message }, status: :not_found. Always include a machine-readable error code field alongside the human-readable message so API clients can branch on error type without parsing strings. Common Rails status symbols: :not_found (404), :unprocessable_entity (422), :unauthorized (401), :forbidden (403).
Further reading and primary sources
- Rails API Documentation — Official Rails guide for API-only applications
- ActiveModel Serializers — ActiveModel::Serializer documentation
- jsonapi-serializer — Fast JSON API serializer for Ruby objects