# Respondo ๐ŸŽฏ **Consistent JSON API responses for Rails โ€” in one line.** [![Gem Version](https://badge.fury.io/rb/respondo.svg)](https://rubygems.org/gems/respondo) [![Downloads](https://img.shields.io/gem/dt/respondo.svg)](https://rubygems.org/gems/respondo) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Ruby](https://img.shields.io/badge/Ruby-2.7%2B-red)](https://www.ruby-lang.org/)

The problem every Rails API developer hits

You're building an API consumed by a React frontend and a Flutter app. Three developers on your team. Three different response shapes:

// Developer A
{ "data": [...], "status": "ok" }

// Developer B
{ "result": [...], "success": true }

// Developer C
{ "users": [...] }

Your frontend devs are writing if (res.data || res.result || res.users). Your Flutter devs are filing bugs. Your code reviews are arguments.

Respondo fixes this permanently. One response shape. Every controller. Every developer. Every time.

{
  "success": true,
  "message": "Users fetched",
  "data": [...],
  "meta": {
    "timestamp": "2024-06-15T10:30:00Z",
    "pagination": { "currentPage": 1, "totalPages": 4, "totalCount": 98 }
  }
}

Install

# Gemfile
gem "respondo"
bundle install
rails generate respondo:install

That's it. No include in ApplicationController. No boilerplate. Respondo auto-injects via Railtie.


Your first response (30 seconds)

class UsersController < ApplicationController
  def index
    render_ok(data: User.all, message: "Users fetched")
  end

  def create
    user = User.new(user_params)
    if user.save
      render_created(data: user, message: "Account created")
    else
      render_unprocessable(message: "Validation failed", errors: user.errors)
    end
  end

  def show
    render_ok(data: User.find(params[:id]), message: "User found")
  rescue ActiveRecord::RecordNotFound
    render_not_found(message: "User not found")
  end
end

Your frontend now gets a guaranteed structure โ€” forever.


Why teams switch to Respondo

Without Respondo With Respondo
Each dev invents their own response format One standard, enforced automatically
Frontend code full of defensive `
Pagination shape differs per endpoint Pagination always in meta.pagination
Validation errors in different keys Always in errors, always a hash
render json: boilerplate in every action One expressive method call
camelCase conversion scattered across code config.camelize_keys = true โ€” done

What every response looks like

Every response โ€” success or error โ€” has the same four keys:

Key Type Description
success Boolean true or false โ€” always present
message String Human-readable description
data Object / Array / nil The payload
meta Object Timestamp + pagination + optional request_id

Error responses additionally include errors โ€” a hash of { field: ["message"] }.

Success:

{
  "success": true,
  "message": "Post published",
  "data": { "id": 42, "title": "Hello World" },
  "meta": { "timestamp": "2024-06-15T10:30:00Z" }
}

Validation error:

{
  "success": false,
  "message": "Validation failed",
  "data": null,
  "errors": { "email": ["is invalid"], "name": ["can't be blank"] },
  "meta": { "timestamp": "2024-06-15T10:30:00Z" }
}

Real-world controller (with pagination)

class PostsController < ApplicationController

  def index
    @posts = Post.published.page(params[:page]).per(params[:per_page] || 20)

    render_ok(
      data:    @posts,
      message: "Posts fetched",
      pagination: {
        current_page: @posts.current_page,
        next_page:    @posts.next_page,
        prev_page:    @posts.prev_page,
        total_pages:  @posts.total_pages,
        total_count:  @posts.total_count,
        per_page:     @posts.limit_value
      }
    )
  end

  def create
    @post = current_user.posts.build(post_params)

    if @post.save
      render_created(data: @post, message: "Post published")
    else
      render_unprocessable(message: "Could not create post", errors: @post.errors)
    end
  end

  def update
    @post = Post.find(params[:id])

    return render_forbidden(message: "Not your post") unless @post.user == current_user

    if @post.update(post_params)
      render_ok(data: @post, message: "Post updated")
    else
      render_unprocessable(message: "Update failed", errors: @post.errors)
    end
  rescue ActiveRecord::RecordNotFound
    render_not_found(message: "Post not found")
  end

end

Auto-Serialization

Respondo automatically handles:

Input type Output
ActiveRecord::Base instance record.as_json
ActiveRecord::Relation Array of as_json records
ActiveModel::Errors { field: ["message", ...] }
Hash Passed through (values serialized)
Array Each element serialized recursively
Exception { message: e.message }
Anything with #as_json .as_json
Anything with #to_h .to_h
Primitives (String, Integer...) As-is

Configuration

Run the interactive generator โ€” it walks you through every option:

rails generate respondo:install

Or write it manually:

# config/initializers/respondo.rb
Respondo.configure do |config|
  config.default_success_message = "OK"
  config.default_error_message   = "Something went wrong"
  config.include_request_id      = true   # adds request_id to every meta
  config.camelize_keys           = true   # snake_case โ†’ camelCase (Flutter/JS friendly)
  config.default_meta            = { api_version: "v1" }
end

camelCase output (great for Flutter / React Native)

config.camelize_keys = true
{
  "success": true,
  "data": { "firstName": "Alice", "createdAt": "2024-01-01" },
  "meta": { "totalPages": 4, "currentPage": 1 }
}

Custom serializer

config.serializer = ->(obj) { MySerializer.new(obj).as_json }

Add this to ApplicationController to handle exceptions app-wide without try/rescue in every action:

class ApplicationController < ActionController::API
  rescue_from ActiveRecord::RecordNotFound do |e|
    render_not_found(message: e.message)
  end

  rescue_from ActionController::ParameterMissing do |e|
    render_bad_request(message: e.message)
  end

  rescue_from StandardError do |e|
    Rails.logger.error(e.full_message)
    render_server_error(message: "An unexpected error occurred")
  end
end

Complete HTTP helper reference

Respondo covers every HTTP status code. Here are the helpers you'll use every day:

2xx โ€” Success

Helper Status When to use
render_ok 200 Standard success
render_created 201 After POST creates a resource
render_accepted 202 Async jobs โ€” request queued
render_no_content 200* After DELETE โ€” no body

*Rails renders 200 with a JSON body for render_no_content to preserve consistent structure.

render_ok(data: @user, message: "Profile fetched")
render_created(data: @order, message: "Order placed")
render_accepted(data: { job_id: "abc123" }, message: "Export queued โ€” you'll get an email")
render_no_content(message: "Account deleted")

4xx โ€” Client errors

Helper Status When to use
render_bad_request 400 Malformed input
render_unauthorized 401 Not logged in / token expired
render_forbidden 403 Logged in but not allowed
render_not_found 404 Record doesn't exist
render_conflict 409 Duplicate (e.g. email taken)
render_unprocessable 422 Validation errors
render_too_many_requests 429 Rate limiting
render_unauthorized(message: "Token has expired", errors: { token: ["has expired"] })
render_forbidden(message: "You can only edit your own posts")
render_not_found(message: "User ##{params[:id]} not found")
render_unprocessable(message: "Validation failed", errors: user.errors)
render_conflict(message: "Email already registered", errors: { email: ["has already been taken"] })
render_too_many_requests(message: "Slow down โ€” 100 req/min max", meta: { retry_after: 60 })

5xx โ€” Server errors

render_server_error(message: "Something went wrong. Our team has been notified.")
render_service_unavailable(message: "Down for maintenance. Back in 30 minutes.", meta: { retry_after: 1800 })
render_bad_gateway(message: "Payment processor is unreachable โ€” you have not been charged")
Full list of all helpers (click to expand) #### 1xx โ€” Informational `render_continue` ยท `render_switching_protocols` ยท `render_processing` ยท `render_early_hints` #### 2xx โ€” Success `render_ok` ยท `render_created` ยท `render_accepted` ยท `render_non_authoritative` ยท `render_no_content` ยท `render_reset_content` ยท `render_partial_content` ยท `render_multi_status` ยท `render_already_reported` ยท `render_im_used` #### 3xx โ€” Redirect `render_multiple_choices` ยท `render_moved_permanently` ยท `render_found` ยท `render_see_other` ยท `render_not_modified` ยท `render_temporary_redirect` ยท `render_permanent_redirect` #### 4xx โ€” Client Error `render_bad_request` ยท `render_unauthorized` ยท `render_payment_required` ยท `render_forbidden` ยท `render_not_found` ยท `render_method_not_allowed` ยท `render_not_acceptable` ยท `render_proxy_auth_required` ยท `render_request_timeout` ยท `render_conflict` ยท `render_gone` ยท `render_length_required` ยท `render_precondition_failed` ยท `render_payload_too_large` ยท `render_uri_too_long` ยท `render_unsupported_media_type` ยท `render_range_not_satisfiable` ยท `render_expectation_failed` ยท `render_im_a_teapot` ยท `render_misdirected_request` ยท `render_unprocessable` ยท `render_locked` ยท `render_failed_dependency` ยท `render_too_early` ยท `render_upgrade_required` ยท `render_precondition_required` ยท `render_too_many_requests` ยท `render_request_header_fields_too_large` ยท `render_unavailable_for_legal_reasons` #### 5xx โ€” Server Error `render_server_error` ยท `render_not_implemented` ยท `render_bad_gateway` ยท `render_service_unavailable` ยท `render_gateway_timeout` ยท `render_http_version_not_supported` ยท `render_variant_also_negotiates` ยท `render_insufficient_storage` ยท `render_loop_detected` ยท `render_not_extended` ยท `render_network_authentication_required`

Testing your responses

Respondo ships with test helpers for RSpec and Minitest so you can assert on response structure directly.

RSpec

# spec/rails_helper.rb
require "respondo/testing/rspec"

RSpec.describe UsersController, type: :request do
  describe "GET /users" do
    it "returns a success response" do
      get "/users"
      expect(response).to be_respondo_success
      expect(response).to have_respondo_message("Users fetched")
    end
  end

  describe "POST /users with invalid params" do
    it "returns validation errors" do
      post "/users", params: { user: { email: "" } }
      expect(response).to be_respondo_error
      expect(response).to have_respondo_errors(:email)
    end
  end
end

Minitest

# test/test_helper.rb
require "respondo/testing/minitest"

class UsersControllerTest < ActionDispatch::IntegrationTest
  def test_index_returns_success
    get users_url
    assert_respondo_success response
    assert_respondo_message "Users fetched", response
  end
end

What's next

  • [ ] ActiveModelSerializers / Blueprinter auto-integration
  • [ ] OpenAPI / Swagger schema generation from Respondo helpers
  • [ ] Rack middleware for zero-config global exception handling

Have a feature idea? Open an issue โ†’


Contributing

Bug reports and pull requests are welcome on GitHub.

git clone https://github.com/spatelpatidar/respondo
cd respondo
bundle install
bundle exec rspec

License

Released under the MIT License.


If Respondo saved you an hour, give it a โญ โ€” it helps other developers find it.