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. = "OK"
config. = "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. = { 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 }
Global error handling (recommended pattern)
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.)
end
rescue_from ActionController::ParameterMissing do |e|
render_bad_request(message: e.)
end
rescue_from StandardError do |e|
Rails.logger.error(e.)
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_contentto 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 |
(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 ("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
"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.