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
end

The 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
FeatureFull Rails App--api Mode
Views / ERB templatesYesNo
Asset pipeline / SprocketsYesNo
Cookie / session middlewareYesNo (addable)
Routing, Active Record, Strong ParamsYesYes
ActionController::API baseNoYes
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.0

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

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

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

Definitions

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::Serializer and JSONAPI::Serializer. Serializers decouple the database schema from the API contract and centralize output shape in one place.
strong parameters
An ActionController::Parameters mechanism that requires explicit whitelisting of request fields before they can be passed to model methods. Calling params.require(:product).permit(:name, :price) returns only the permitted keys, preventing mass-assignment attacks where an attacker could update fields like admin or role from a JSON body.
render json:
A Rails controller method that serializes an object to JSON (via to_json), sets the Content-Type: application/json response header, and returns the HTTP response. Accepts only:, except:, include:, and status: 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.jbuilder view 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 with ApplicationController inheriting from ActionController::API instead of ActionController::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