Introduction
Consistent JSON response formatting is crucial for API usability. Rails provides several approaches for controlling what data is included in responses, from simple as_json overrides to Jbuilder templates.
Key Concepts
as_json: A method you override on models to control their default JSON representation.render json:: Controller method that converts objects to JSON. Callsas_jsonon the object.- Envelope Pattern: Wrapping response data in a top-level key (e.g.,
{ "data": [...] }). - Jbuilder: A Rails DSL for building JSON responses with fine-grained control.
Real World Context
API clients need predictable response shapes. Every endpoint should follow the same structure. Inconsistent responses make APIs hard to consume and lead to fragile client code.
Deep Dive
Controlling Output with as_json
Override as_json on your model to set the default JSON shape:
rubyclass Product < ApplicationRecord def as_json(options = {}) super(options.merge( only: [:id, :name, :price, :description], methods: [:formatted_price], include: { category: { only: [:id, :name] } } )) end def formatted_price "$#{price.to_f / 100}" end end
Now render json: @product automatically uses this shape. The only option whitelists fields, methods adds computed values, and include nests associations.
Using Jbuilder for Complex Responses
For more control, use Jbuilder templates:
ruby# app/views/api/v1/products/index.json.jbuilder json.data @products do |product| json.id product.id json.name product.name json.price product.formatted_price json.category do json.id product.category.id json.name product.category.name end json.links do json.self api_v1_product_url(product) end end json.meta do json.total @products.count end
Jbuilder templates live alongside HTML views and give you a clean DSL for building nested JSON structures. The controller just sets instance variables:
rubydef index @products = Product.includes(:category).all # Renders index.json.jbuilder automatically end
Consistent Error Responses
ruby# app/controllers/application_controller.rb class ApplicationController < ActionController::API rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActionController::ParameterMissing, with: :bad_request private def not_found(exception) render json: { error: { message: exception.message, status: 404 } }, status: :not_found end def bad_request(exception) render json: { error: { message: exception.message, status: 400 } }, status: :bad_request end end
Using rescue_from at the application level ensures every 404 and 400 follows the same format.
Common Pitfalls
- Exposing sensitive fields — Without
onlyorexcept,render json: @userexposes everything includingpassword_digest,email, and internal fields. - Inconsistent response shapes — If some endpoints wrap in
{ data: ... }and others don't, clients need special handling for each endpoint.
Best Practices
- Always whitelist fields — Use
only:inas_jsonor Jbuilder to explicitly list fields. Never render full model attributes. - Standardize error format — Every error response should follow the same structure across all endpoints.
Summary
- Override
as_jsonfor simple JSON customization with field whitelisting. - Use Jbuilder templates for complex nested responses with computed fields.
- Use
rescue_fromfor consistent error response formatting. - Always whitelist fields to avoid exposing sensitive data.
- Wrap collections in an envelope (
data,meta) for extensibility.
Code Examples
# Consistent error handling in the application controller
class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::ParameterMissing, with: :bad_request
private
def not_found(e)
render json: { error: { message: e.message, status: 404 } },
status: :not_found
end
def bad_request(e)
render json: { error: { message: e.message, status: 400 } },
status: :bad_request
end
end