JSON in Ruby on Rails: render json:, API Mode, Jbuilder & Active Model Serializers

Last updated:

Ruby on Rails renders JSON from any controller action with render json: object — passing an ActiveRecord model, array, or plain Ruby hash produces a Content-Type: application/json response. render json: user, only: [:id, :name, :email] whitelists which attributes appear, preventing password hashes and internal fields from leaking into API responses. Rails automatically parses Content-Type: application/json request bodies into params, so params[:user] works the same for JSON and form-encoded requests. Rails --api mode strips 26 unused middleware layers for pure JSON APIs. jbuilder (included by default) provides ERB-style JSON template files for complex response shapes. This guide covers render json:, field whitelisting with only:/except:, reading JSON request bodies, --api mode, Jbuilder templates, Active Model Serializers, and returning standard JSON error responses.

render json: — Returning JSON from Rails Controllers

render json: is the core Rails mechanism for returning JSON responses. It accepts any Ruby object — ActiveRecord models, arrays, hashes, or any object that responds to as_json — serializes it with JSON.generate, and sets the Content-Type: application/json header automatically. No middleware configuration or manual header setting is needed.

# app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  before_action :set_user, only: [:show, :update, :destroy]

  # GET /api/v1/users
  def index
    @users = User.all
    render json: @users
    # → Content-Type: application/json
    # → [{"id":1,"name":"Alice","email":"alice@example.com","password_digest":"$2a$..."}]
    # ⚠ Exposes password_digest — use only: in production
  end

  # GET /api/v1/users/:id
  def show
    render json: @user
    # Rails calls @user.as_json internally
  end

  # POST /api/v1/users
  def create
    @user = User.new(user_params)
    if @user.save
      render json: @user, status: :created
      # HTTP 201 Created + JSON body
    else
      render json: { errors: @user.errors.full_messages },
             status: :unprocessable_entity
      # HTTP 422 + error array
    end
  end

  # DELETE /api/v1/users/:id
  def destroy
    @user.destroy
    head :no_content
    # HTTP 204 — no body, correct for DELETE success
    # never: render json: nil  (serializes as "null")
  end

  # Returning a plain hash — no model needed
  def health
    render json: { status: "ok", time: Time.current.iso8601 }
  end

  # Returning an array of hashes
  def stats
    render json: [
      { metric: "users_count",  value: User.count },
      { metric: "active_today", value: User.where("last_seen > ?", 1.day.ago).count },
    ]
  end

  # respond_to for content-type negotiation
  # (same controller serves HTML and JSON)
  def dashboard
    @data = build_dashboard_data
    respond_to do |format|
      format.html # → renders dashboard.html.erb
      format.json { render json: @data }
    end
  end

  private

  def set_user
    @user = User.find(params[:id])
  end

  def user_params
    params.require(:user).permit(:name, :email, :password)
  end
end

By default, render json: serializes all columns returned by ActiveRecord — including password_digest, reset_password_token, and any other sensitive fields. Always use only: in the next section to control which fields are exposed. The status: option accepts Ruby symbols (:created, :not_found) or integer codes (201, 404). head :no_content sends HTTP 204 with no body — the semantically correct response for successful DELETE, and lighter than render json: {}.

Filtering Fields with only: and except:

only: whitelists the fields included in the JSON output; except: blacklists specific fields. only: is the safer default — it silently ignores any new database column added later, preventing accidental data leaks. except: is convenient for excluding a few known-sensitive columns but will expose any new column added to the table.

# ── only: — whitelist specific fields ─────────────────────────
# Safer: new columns are ignored by default
render json: @user, only: [:id, :name, :email]
# → {"id":1,"name":"Alice","email":"alice@example.com"}
# password_digest, reset_password_token, etc. silently omitted

# ── except: — blacklist sensitive fields ───────────────────────
# Riskier: future columns are exposed automatically
render json: @user, except: [:password_digest, :reset_password_token,
                              :remember_digest, :admin]
# → exposes any new column added to users table

# ── methods: — include virtual attributes ──────────────────────
# Include Ruby methods as if they were database columns
class User < ApplicationRecord
  def full_name
    "#{first_name} #{last_name}"
  end

  def account_age_days
    (Date.today - created_at.to_date).to_i
  end
end

render json: @user,
       only: [:id, :email],
       methods: [:full_name, :account_age_days]
# → {"id":1,"email":"alice@example.com","full_name":"Alice Smith","account_age_days":42}

# ── include: — serialize associations ─────────────────────────
# Include a belongs_to association with field filtering
render json: @post,
       only: [:id, :title, :body, :published_at],
       include: {
         author: { only: [:id, :name] },
         tags:   { only: [:id, :name] }
       }
# → {"id":5,"title":"Hello","body":"...","published_at":"...","author":{...},"tags":[...]}

# ── as_json on the model — reusable serialization ──────────────
# Define a custom as_api_json method on the model
class User < ApplicationRecord
  def as_api_json
    {
      id:         id,
      name:       name,
      email:      email,
      created_at: created_at.iso8601,
    }
  end
end

# Use it across controllers without repeating only:
render json: @users.map(&:as_api_json)

# ── Rendering collections with field filtering ─────────────────
render json: @users, only: [:id, :name, :email]
# Works on ActiveRecord::Relation — applies only: to every record

# ── Root wrapping ──────────────────────────────────────────────
# If include_root_in_json is enabled (off by default):
render json: @user, root: "user"
# → {"user":{"id":1,"name":"Alice",...}}

For APIs consumed by external clients or mobile apps, define the set of safe public fields once in a model method or serializer rather than scattering only: arrays across every controller action. When the public field set changes, you update one place. The methods: option is useful for calculated attributes like full_name, avatar_url, or is_active that are not database columns but are part of the API contract.

Reading JSON Request Bodies via params

Rails automatically parses incoming application/json request bodies into the params hash via ActionDispatch::Request::Utils.deep_munge and ParamsParser middleware. Strong parameters work identically for JSON and form-encoded bodies — the controller code does not need to know which content type was used.

# Client sends:
# POST /api/v1/users
# Content-Type: application/json
# Body: {"user":{"name":"Alice","email":"alice@example.com","role":"admin"}}

# ── Strong parameters — same as form data ──────────────────────
def create
  @user = User.new(user_params)
  # params[:user] = {"name"=>"Alice","email"=>"alice@example.com","role"=>"admin"}
  # user_params filters out :role (not permitted)
  if @user.save
    render json: @user, only: [:id, :name, :email], status: :created
  else
    render json: { errors: @user.errors.full_messages },
           status: :unprocessable_entity
  end
end

private

def user_params
  params.require(:user).permit(:name, :email, :password)
  # :role is stripped — never reaches the model
end

# ── Top-level JSON (no root key) ────────────────────────────────
# Client sends: {"name":"Alice","email":"alice@example.com"}
# Rails 5.1+ wraps top-level keys under the controller name
# params = {"name"=>"Alice","email"=>"alice@example.com","user"=>{...}}
# Access via: params.permit(:name, :email)

# ── Nested arrays in JSON body ─────────────────────────────────
# Client sends: {"order":{"line_items":[{"product_id":1,"qty":2}]}}
def order_params
  params.require(:order).permit(:total, line_items: [:product_id, :qty])
  # line_items: [:product_id, :qty] whitelists array of hashes
end

# ── Reading raw JSON body (for HMAC webhook verification) ──────
def webhook
  # Must read body BEFORE params is accessed
  payload = request.body.read
  signature = request.headers["X-Hub-Signature-256"]

  unless valid_signature?(payload, signature)
    render json: { error: "Invalid signature" }, status: :unauthorized
    return
  end

  # Now parse it
  data = JSON.parse(payload)
  process_webhook(data)
  head :ok
end

def valid_signature?(payload, signature)
  expected = "sha256=" + OpenSSL::HMAC.hexdigest(
    "SHA256", Rails.application.credentials.webhook_secret, payload
  )
  ActiveSupport::SecurityUtils.secure_compare(expected, signature.to_s)
end

# ── request.body.read vs params ────────────────────────────────
# request.body is an IO object — calling .read returns the raw string once.
# After params is accessed, Rails may consume the body stream.
# Rule: if you need raw body, read it first and store it.
before_action :cache_raw_body, only: [:webhook]

def cache_raw_body
  request.body.rewind
  @raw_body = request.body.read
  request.body.rewind  # allow params to re-parse
end

The key insight is that Rails' param parsing makes JSON APIs transparent to controller code — the same strong parameter patterns used for HTML form submissions protect against mass assignment in JSON APIs. Always use params.require(...).permit(...) and never trust raw params directly. For webhook endpoints that need the raw body for HMAC verification, read request.body.read before any code accesses params.

Rails API Mode: Building JSON-Only Applications

rails new myapp --api generates a stripped-down application stack optimized for JSON APIs. The application inherits from ActionController::API instead of ActionController::Base, removing ~26 middleware layers that are only relevant for HTML applications.

# Generate a new API-only Rails app
rails new myapp --api --database=postgresql

# ── What --api removes ─────────────────────────────────────────
# ActionDispatch::Static          (static file serving)
# ActionDispatch::Cookies         (cookie jar)
# ActionDispatch::Session::CookieStore
# ActionDispatch::Flash           (flash message middleware)
# Rack::MethodOverride            (_method param override)
# ActionDispatch::ContentSecurityPolicy::Middleware
# ... and ~20 more view/browser-related middleware layers

# ── app/controllers/application_controller.rb ─────────────────
class ApplicationController < ActionController::API
  # ActionController::API includes:
  #   - Basic rendering (render json:, head)
  #   - before_action / around_action / after_action
  #   - Strong parameters
  #   - Routing
  #   - Response helpers (render, head)
  # NOT included: sessions, cookies, flash, helpers, layouts

  # Add JWT authentication globally
  before_action :authenticate_request!

  rescue_from ActiveRecord::RecordNotFound do |e|
    render json: { error: "Resource not found" }, status: :not_found
  end

  rescue_from ActionController::ParameterMissing do |e|
    render json: { error: e.message }, status: :bad_request
  end

  rescue_from ActionDispatch::Http::Parameters::ParseError do
    render json: { error: "Invalid JSON body" }, status: :bad_request
  end

  private

  def authenticate_request!
    token = request.headers["Authorization"]&.split(" ")&.last
    @current_user = JwtService.decode(token)
  rescue JWT::DecodeError
    render json: { error: "Unauthorized" }, status: :unauthorized
  end
end

# ── Enabling CORS for API mode ─────────────────────────────────
# Gemfile
gem "rack-cors"

# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "https://myapp.com", "http://localhost:3000"
    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      expose: ["Authorization"]
  end
end

# ── Routes for a RESTful JSON API ──────────────────────────────
# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :users,   only: [:index, :show, :create, :update, :destroy]
      resources :posts,   only: [:index, :show, :create, :update, :destroy] do
        resources :comments, only: [:index, :create]
      end
      resource  :session, only: [:create, :destroy]  # login/logout
    end
  end

  get "/health", to: "health#show"
end

API mode is the right choice when Rails is the backend for a React, Vue, Next.js, or mobile frontend. When the same Rails app also serves HTML (a hybrid app), use the full stack and add ActionController::API behavior selectively with modules. In API mode, session-dependent gems like Devise require additional configuration — use devise-jwt or implement token authentication manually with a JwtService as shown above.

Jbuilder Templates for Complex JSON Responses

Jbuilder ships in the default Rails Gemfile and lets you define JSON structure in .json.jbuilder view files. It is rendered automatically when a controller action responds to format.json without an explicit render json: call. Jbuilder excels at complex nested responses, partial reuse, and fragment caching.

# ── app/views/api/v1/posts/show.json.jbuilder ─────────────────
json.extract! @post, :id, :title, :body, :published_at
# → "id":5,"title":"Hello","body":"...","published_at":"..."

json.url api_v1_post_url(@post)
# → "url":"https://api.example.com/api/v1/posts/5"

json.author do
  json.extract! @post.author, :id, :name
  json.avatar_url @post.author.avatar_url
end
# → "author":{"id":1,"name":"Alice","avatar_url":"https://..."}

json.tags @post.tags do |tag|
  json.extract! tag, :id, :name
end
# → "tags":[{"id":1,"name":"ruby"},{"id":2,"name":"rails"}]

json.stats do
  json.view_count   @post.view_count
  json.comment_count @post.comments.count
end

# ── app/views/api/v1/posts/index.json.jbuilder ────────────────
json.posts @posts do |post|
  json.partial! "api/v1/posts/post", post: post
end

json.meta do
  json.total_count @posts.total_count
  json.current_page @posts.current_page
  json.total_pages @posts.total_pages
end

# ── app/views/api/v1/posts/_post.json.jbuilder ────────────────
# Shared partial reused by index, show, related endpoints
json.extract! post, :id, :title, :slug, :published_at
json.author_name post.author.name
json.url api_v1_post_url(post)

# ── Fragment caching in Jbuilder ───────────────────────────────
json.cache! ["v1", @post], expires_in: 5.minutes do
  json.extract! @post, :id, :title, :body
  json.author do
    json.extract! @post.author, :id, :name
  end
end

# ── Controller — Jbuilder renders automatically ────────────────
class Api::V1::PostsController < ApplicationController
  def show
    @post = Post.includes(:author, :tags).find(params[:id])
    # No render call needed — Rails renders show.json.jbuilder
    # when Accept: application/json or format.json
  end

  def index
    @posts = Post.published.page(params[:page]).per(20)
    # Renders index.json.jbuilder
  end
end

# ── Using Jbuilder for custom shapes ──────────────────────────
# You can also render Jbuilder templates explicitly:
render "api/v1/posts/show"
render template: "api/v1/shared/error", locals: { message: "Not found" }

Jbuilder's json.cache! integrates with Rails' cache store (Redis, Memcached) for fragment-level caching of expensive JSON nodes. When the cache key expires or the underlying record changes, only that fragment is regenerated. For APIs returning deeply nested objects — posts with authors, tags, comments, and comment authors — Jbuilder partials are significantly easier to maintain than a single large render json: call with many include: nesting levels.

Active Model Serializers for Reusable JSON Presentation

Active Model Serializers (AMS) separates JSON presentation from model logic by defining serializer classes alongside models. While the AMS gem (active_model_serializers) has historically been popular, the ecosystem has largely moved toward jsonapi-serializer (formerly fast_jsonapi) for performance-critical APIs and Jbuilder for simpler cases.

# Gemfile
gem "active_model_serializers"
# or for JSON:API format + higher performance:
gem "jsonapi-serializer"

# ── Active Model Serializers ───────────────────────────────────
# app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :email, :created_at

  # Virtual attribute — computed, not a DB column
  attribute :avatar_url do
    object.avatar.url
  end

  # Association
  has_many :posts, serializer: PostSummarySerializer
  belongs_to :organization
end

# app/serializers/post_summary_serializer.rb
class PostSummarySerializer < ActiveModel::Serializer
  attributes :id, :title, :published_at
end

# Controller — AMS renders automatically when a serializer exists
class Api::V1::UsersController < ApplicationController
  def show
    @user = User.find(params[:id])
    render json: @user
    # Rails finds UserSerializer automatically → uses it
    # No manual serializer instantiation needed
  end

  def index
    @users = User.all
    render json: @users
    # Renders each user through UserSerializer
  end

  # Explicit serializer selection
  def public_profile
    @user = User.find(params[:id])
    render json: @user, serializer: PublicUserSerializer
  end
end

# ── jsonapi-serializer (faster, JSON:API format) ───────────────
# app/serializers/user_serializer.rb
class UserSerializer
  include JSONAPI::Serializer

  attributes :name, :email, :created_at

  attribute :avatar_url do |user|
    user.avatar.url
  end

  has_many :posts
  belongs_to :organization
end

# Controller
def show
  @user = User.find(params[:id])
  render json: UserSerializer.new(@user).serializable_hash
end

def index
  @users = User.all
  render json: UserSerializer.new(@users).serializable_hash
  # JSON:API envelope: { data: [...], included: [...] }
end

# ── Choosing between approaches ────────────────────────────────
# render json: with only:   → simplest, no extra gems, 1-2 fields to hide
# Jbuilder                  → complex nesting, fragment caching, partial reuse
# Active Model Serializers  → many models share a JSON shape, Rails convention
# jsonapi-serializer        → JSON:API compliance required, high throughput

The jsonapi-serializer gem benchmarks 25x faster than AMS for large collections because it avoids object allocation per record and uses a single-pass serialization loop. For APIs returning lists of 100+ records per request, the performance difference is measurable. For small APIs with simple models, AMS or Jbuilder remain practical. The best rule: start with render json: @model, only: [...], graduate to Jbuilder when response shapes become complex, and evaluate jsonapi-serializer when collection serialization becomes a bottleneck.

JSON Error Responses and rescue_from

Consistent JSON error responses are critical for API clients. Define error handling once in ApplicationController using rescue_from so every controller inherits the same error shapes. Rails symbol status names map cleanly to HTTP codes, and rescue_from catches exceptions before they reach the default HTML error pages.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  # ── Centralized error handling ─────────────────────────────
  rescue_from ActiveRecord::RecordNotFound do |e|
    render json: {
      error: "Not found",
      detail: e.message,
    }, status: :not_found  # HTTP 404
  end

  rescue_from ActiveRecord::RecordInvalid do |e|
    render json: {
      error:  "Validation failed",
      errors: e.record.errors.full_messages,
    }, status: :unprocessable_entity  # HTTP 422
  end

  rescue_from ActionController::ParameterMissing do |e|
    render json: {
      error:  "Missing parameter",
      detail: e.message,
    }, status: :bad_request  # HTTP 400
  end

  rescue_from ActionDispatch::Http::Parameters::ParseError do
    render json: {
      error: "Invalid JSON body",
    }, status: :bad_request  # HTTP 400
  end

  rescue_from Pundit::NotAuthorizedError do
    render json: {
      error: "Forbidden",
    }, status: :forbidden  # HTTP 403
  end
end

# ── Inline error rendering in controllers ──────────────────────
class Api::V1::PostsController < ApplicationController
  def create
    @post = Post.new(post_params)
    if @post.save
      render json: @post, only: [:id, :title, :slug], status: :created
    else
      # Model validation errors → 422
      render json: {
        errors: @post.errors.full_messages,
        fields: @post.errors.messages,  # per-field error hash
      }, status: :unprocessable_entity
    end
  end

  def update
    if @post.update(post_params)
      render json: @post, only: [:id, :title, :updated_at]
    else
      render json: { errors: @post.errors.full_messages },
             status: :unprocessable_entity
    end
  end
end

# ── RFC 7807 Problem Details format ───────────────────────────
# Standard JSON error envelope for machine-readable errors
rescue_from ActiveRecord::RecordNotFound do |e|
  render json: {
    type:     "https://example.com/errors/not-found",
    title:    "Resource Not Found",
    status:   404,
    detail:   e.message,
    instance: request.path,
  }, status: :not_found,
     content_type: "application/problem+json"
end

# ── Rails status symbol → HTTP code reference ─────────────────
# :ok                    200
# :created               201
# :accepted              202
# :no_content            204
# :bad_request           400
# :unauthorized          401
# :forbidden             403
# :not_found             404
# :unprocessable_entity  422
# :too_many_requests     429
# :internal_server_error 500
# :service_unavailable   503

Centralizing rescue_from in ApplicationController ensures that all API endpoints return consistent JSON error shapes — avoiding mixed HTML 500 pages and JSON responses that break client error handling. For ActiveRecord::RecordInvalid, e.record.errors.full_messages returns an array of human-readable strings like ["Name can't be blank", "Email is invalid"], while e.record.errors.messages returns a hash keyed by field name — useful for mapping errors back to form fields in frontend applications. The RFC 7807 application/problem+json content type is the emerging standard for structured error responses in REST APIs.

Key Terms

render json:
The Rails controller method that serializes a Ruby object to a JSON string, sets the Content-Type: application/json response header, and sends the response body. It accepts only:, except:, methods:, include:, and status: options. Internally calls object.as_json then JSON.generate. Available in both ActionController::Base (full stack) and ActionController::API (API mode). Returns HTTP 200 by default unless status: is specified.
strong parameters
The Rails mechanism that prevents mass-assignment vulnerabilities by requiring explicit whitelisting of permitted parameters via params.require(...).permit(...). In JSON APIs, strong parameters filter the auto-parsed JSON request body the same way they filter HTML form data — the controller code is content-type agnostic. Unpermitted parameters are silently dropped by default; setting config.action_controller.action_on_unpermitted_parameters = :raise raises an error instead, useful for debugging.
ActionController::API
The slimmed-down base class used by Rails API mode (rails new --api). It includes only the modules needed for JSON API responses — routing, rendering, strong parameters, and callbacks — and omits session, cookie, flash, view helper, and browser-caching middleware. Inheriting from ActionController::API instead of ActionController::Base removes approximately 26 middleware layers, reducing memory usage and per-request processing time. All controllers in an API-mode application inherit from it via ApplicationController < ActionController::API.
Jbuilder
A Rails gem (included in the default Gemfile) that provides a Ruby DSL for building JSON in .json.jbuilder view files. Jbuilder templates live in app/views alongside HTML templates and follow the same naming conventions — posts/show.json.jbuilder renders for GET /posts/:id with Accept: application/json. It supports partials (json.partial!), fragment caching (json.cache!), conditional logic, and nested blocks. The DSL separates JSON presentation from controller logic, making it easy to share response structures via partials.
rescue_from
An ActionController class method that registers a handler for a specific exception class. When any action in the controller (or its subclasses) raises the registered exception, Rails calls the handler instead of propagating the error. In API applications, rescue_from in ApplicationController centralizes JSON error responses for common exceptions like ActiveRecord::RecordNotFound (404), ActiveRecord::RecordInvalid (422), and ActionController::ParameterMissing (400). More specific rescue handlers take precedence over more general ones.
as_json
The Ruby method called by Rails to convert an object to a hash before JSON serialization. ActiveRecord models, arrays, hashes, strings, numbers, and booleans all implement as_json. You can override as_json on a model to customize its default JSON representation — useful when a model is serialized in many places and always needs the same field set. The only:, except:, methods:, and include: options passed to render json: are forwarded to as_json. to_json calls as_json then serializes the result to a JSON string.

FAQ

How do I return a JSON response in Rails?

Call render json: followed by any Ruby object in a controller action. render json: @user serializes an ActiveRecord model; render json: {'{ id: 1, name: "Alice" }'} serializes a plain Hash; render json: @users serializes an array into a JSON array. Rails sets Content-Type: application/json automatically and returns HTTP 200 unless you specify status: — use status: :created (201) for POST success, status: :no_content (204) for DELETE (or use head :no_content for no body). Never use render json: nil — it serializes to the literal string "null" which confuses most API clients. The render json: method is available in both the full stack (ActionController::Base) and API mode (ActionController::API).

How do I filter which fields appear in a Rails JSON response?

Pass only: or except: to render json:. render json: @user, only: [:id, :name, :email] whitelists exactly 3 fields and silently drops every other attribute including password_digest, created_at, and any future columns added to the users table. render json: @user, except: [:password_digest] blacklists specific sensitive fields but exposes any new column added later — making only: the safer default. For virtual attributes (Ruby methods, not database columns), add methods: [:full_name, :avatar_url]. For nested associations: render json: @post, only: [:id, :title], include: {'{ author: { only: [:id, :name] } }'}. To avoid repeating only: across many controller actions, define an as_api_json method on the model or use a Jbuilder template.

How do I read a JSON request body in Rails?

Rails automatically parses application/json request bodies into the params hash. A JSON body like {"user":{"name":"Alice","email":"alice@example.com"}} makes params[:user][:name] return "Alice" — identical to form-encoded input. Use strong parameters as normal: params.require(:user).permit(:name, :email). For nested JSON arrays, use permit(items: [:product_id, :quantity]). For raw JSON bodies (HMAC webhook verification), read request.body.read before params is accessed — Rails may consume the IO stream during param parsing. Call request.body.rewind afterward to allow normal param parsing to continue. If a client sends JSON without a root key, Rails 5.1+ wraps top-level keys under a controller-derived namespace automatically.

What is Rails API mode and when should I use it?

Rails API mode (rails new myapp --api) generates a slimmed-down application that inherits from ActionController::API instead of ActionController::Base, removing approximately 26 middleware layers for HTML rendering — including cookie handling, flash messages, browser caching, and view rendering. Use API mode when your Rails app will never serve HTML and will only be consumed by a separate frontend (React, Vue, mobile). Keep the full stack when the same application renders ERB or Haml views alongside JSON API endpoints. Switching after creation is possible but tedious — decide at rails new time. Gems like Devise, Doorkeeper, and jwt work in API mode; session-dependent features need alternatives like devise-jwt. CORS configuration (via rack-cors) is necessary in API mode since browsers will make cross-origin requests from the separate frontend.

What is Jbuilder and how does it differ from render json:?

Jbuilder is a DSL for building JSON in .json.jbuilder view files that ships in the default Rails Gemfile. It renders automatically when a controller action responds to the JSON format without an explicit render json:. The key difference from render json: is separation of concerns: Jbuilder templates live in the view layer and can be reused via partials (json.partial!) and cached via fragment caching (json.cache!). json.extract! @post, :id, :title extracts named fields; json.posts @posts, :id, :title renders a JSON array. Jbuilder is the better choice when response shapes are complex (5+ associated objects, conditional fields, nested partials) and when you want fragment caching. render json: with only: is faster for simple flat responses and requires no view files.

How do I return a JSON error response in Rails?

Use render json: {'{ error: "message" }'}, status: :unprocessable_entity for validation failures (HTTP 422) and render json: {'{ error: "Not found" }'}, status: :not_found for missing resources (HTTP 404). For model validation errors, use render json: {'{ errors: @user.errors.full_messages }'}, status: :unprocessable_entityfull_messages returns an array like ["Name can't be blank", "Email is invalid"]. Centralize error handling in ApplicationController with rescue_from: rescue_from ActiveRecord::RecordNotFound do render json: {'{ error: "Not found" }'}, status: :not_found end. Rails status symbols map to HTTP codes: :created (201), :bad_request (400), :unauthorized (401), :forbidden (403), :not_found (404), :unprocessable_entity (422). The RFC 7807 application/problem+json format with type, title, status, and detail fields is the emerging standard for machine-readable API errors.

How do I add pagination to JSON responses in Rails?

The kaminari and pagy gems are the most common choices. With kaminari: @users = User.page(params[:page]).per(20) then render json: {'{ users: @users, meta: { current_page: @users.current_page, total_pages: @users.total_pages, total_count: @users.total_count } }'}. With pagy (faster, fewer database queries): include Pagy::Backend in ApplicationController, then @pagy, @users = pagy(User.all, items: 20) and render json: {'{ users: @users, meta: pagy_metadata(@pagy) }'}. Both return total_count, current_page, and total_pages in a meta envelope — the standard pagination pattern for JSON APIs. Always validate the per_page parameter: limit = [[params[:per_page].to_i, 1].max, 100].min caps it between 1 and 100 to prevent clients from requesting thousands of records.

How do I handle JSON parse errors in Rails?

Rails raises ActionDispatch::Http::Parameters::ParseError (Rails 6+) when a request has Content-Type: application/json but a malformed JSON body. Add rescue_from ActionDispatch::Http::Parameters::ParseError in ApplicationController to return a clean 400 JSON response: rescue_from ActionDispatch::Http::Parameters::ParseError do render json: {'{ error: "Invalid JSON body" }'}, status: :bad_request end. Without this, Rails returns an HTML 400 error page, which breaks JSON API clients. In Rails 5.x, the exception class is ActionDispatch::ParamsParser::ParseError. For manual JSON.parse calls (reading request.body.read), wrap in begin/rescue JSON::ParserError. In RSpec tests, verify the handler works by sending a POST with Content-Type: "application/json" and raw_post: "not valid json" and asserting HTTP 400.

Validate your Rails API JSON automatically

Paste a JSON response from your Rails API into Jsonic's validator to check structure, types, and formatting instantly.

Open JSON Validator

Further reading and primary sources