I’ve built a lot of Rails APIs over the years. Some were clean and a pleasure to work with. Others were inherited messes that taught me exactly what not to do. Here’s what I’ve learned from both.
Why Rails for APIs?
There’s a constant debate in developer circles about whether Rails is “too much” for an API. The argument usually goes that Rails was built for full-stack web apps, so using it for a JSON API means you’re dragging in a bunch of baggage you don’t need.
I disagree. Rails API mode — introduced with rails new myapp --api — strips out the view layer, cookie-based session middleware, and a handful of other things you don’t need. What’s left is a fast, well-organized framework with a 20-year track record of production reliability.
The real advantages: mature ecosystem, excellent ORM with ActiveRecord, solid background job support, and a community that’s seen every problem you’re about to encounter. When you’re building for a manufacturing company or mid-market business, you want boring and reliable. Rails delivers that.
Setting Up Rails in API Mode
Starting fresh is easy:
rails new myapp --api --database=postgresql
That gives you a trimmed-down stack — no ActionView, no sprockets, no cookie sessions. Your ApplicationController inherits from ActionController::API instead of ActionController::Base.
For an existing app being converted to API-only, it’s a bit more work, but manageable.
Authentication That Actually Works
The most common mistake I see in Rails APIs is authentication bolted on as an afterthought. Here’s how I approach it:
JWT for stateless APIs. For most API use cases, JSON Web Tokens make sense. The jwt gem is solid, and combining it with a service object for token generation and verification keeps things clean.
class JsonWebToken
SECRET = Rails.application.credentials.secret_key_base
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET)
end
def self.decode(token)
JWT.decode(token, SECRET)[0]
rescue JWT::DecodeError
nil
end
end
Devise + devise-jwt for apps that already use Devise. It’s opinionated but handles a lot of the edge cases for you.
API keys for machine-to-machine. If you’re building an API that other services will consume — not user-facing — API keys with proper scoping are simpler and more appropriate than JWT.
Versioning Your API
Version from day one. Don’t wait until you need to break a contract and then scramble to figure out how to do it without destroying existing integrations.
The simplest approach: namespace your routes and controllers by version.
# routes.rb
namespace :api do
namespace :v1 do
resources :orders
resources :products
end
end
Your controllers live in app/controllers/api/v1/. When you need to make breaking changes, you add v2 without touching v1. Existing integrations keep working. New ones use the new contract.
Serialization Done Right
ActiveRecord models shouldn’t be your API response format. Use a serialization layer.
I’ve used several options over the years — active_model_serializers, fast_jsonapi (now jsonapi-serializer), and just plain Ruby objects. My current preference is jsonapi-serializer for complex APIs and simple presenter objects for smaller ones.
The point is the same regardless of the tool: keep your API contract separate from your data model. What’s in your database is an implementation detail. What you expose to API consumers is a contract.
Error Handling
Consistent error responses are non-negotiable. Every error from your API should return a predictable JSON structure so clients can handle them programmatically.
# application_controller.rb
rescue_from ActiveRecord::RecordNotFound do |e|
render json: { error: 'not_found', message: e.message }, status: :not_found
end
rescue_from ActiveRecord::RecordInvalid do |e|
render json: { error: 'validation_failed', errors: e.record.errors }, status: :unprocessable_entity
end
Set this up once in ApplicationController and it propagates everywhere.
Performance That Matters
The biggest performance killers I see:
N+1 queries. Every Rails developer knows about this, and yet it keeps showing up. Use includes() to eager load associations. The Bullet gem in development will catch the ones you miss.
No pagination. Returning unbounded collections to API consumers is a disaster waiting to happen. Paginate everything with something like kaminari or pagy. pagy is my current preference — it’s fast and has minimal overhead.
Missing database indexes. Every foreign key, every column you filter or sort on — it needs an index. Run rails db:migrate and check the explain plan for your slow queries.
Too much in the controller. Business logic in controllers kills testability and usually leads to performance problems because you lose visibility into what’s actually happening. Service objects keep things clean.
Testing Rails APIs
Test your API contracts, not just your models. I use RSpec with rails-controller-testing or request specs. Request specs are the better option — they test the full stack including routing, middleware, and serialization.
describe "GET /api/v1/orders" do
it "returns the user's orders" do
user = create(:user)
orders = create_list(:order, 3, user: user)
get "/api/v1/orders", headers: auth_headers(user)
expect(response).to have_http_status(:ok)
expect(json_response["data"].length).to eq(3)
end
end
The Part Everyone Ignores: Documentation
Your API is only as useful as its documentation. If you’re building an API that others will consume, document it. I use rswag to generate OpenAPI docs directly from RSpec specs — it forces you to test and document at the same time.
If you’re building an internal API that only your own frontend consumes, keep a simple API changelog and make sure your frontend devs are in the loop when contracts change.
When Rails API Mode Is the Right Call
Rails API mode makes sense when:
- You have a team that knows Ruby/Rails
- You’re building a complex domain model that benefits from ActiveRecord
- You need the Rails ecosystem (background jobs, mailers, solid testing tools)
- You’re building for long-term maintenance
If you’re building a tiny service that does one thing — maybe just handles webhooks or proxies a third-party API — a lighter option might be more appropriate. But for most real business applications with real domain complexity, Rails API mode is a solid, boring, reliable choice. And boring and reliable is exactly what your clients need.
If you’re working on a Rails API and things have gotten messy, or you’re starting from scratch and want to do it right — I’d be happy to talk. Reach out here.