Rails API Mode — Building JSON APIs Without the Full Stack Overhead
Rails started as a framework for server-rendered HTML applications, and it still excels there. But a significant portion of Rails apps today are pure JSON APIs — backends for mobile apps, frontend JavaScript frameworks, or other services. Rails API mode strips out the parts that don’t apply: views, cookies, sessions, HTML rendering middleware — and gives you a leaner, faster stack purpose-built for JSON. The result is still unmistakably Rails — ActiveRecord, routing, concerns, callbacks, Active Job — just without the browser-specific layers you don’t need.
Creating an API-Only App
Setup:
rails new my_api --api
The --api flag makes three key changes:
ApplicationControllerinherits fromActionController::APIinstead ofActionController::Base- The middleware stack is trimmed — no cookie session, no flash, no browser caching
- View-related generators are skipped — no view templates, no assets
For an existing app, switch by changing the inheritance and updating config/application.rb:
Setup:
# config/application.rb
config.api_only = true
Setup:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
end
Serialization — Shaping Your JSON
The biggest architectural decision in a Rails API is how to serialize models to JSON. Three common approaches:
1. as_json / to_json
The built-in approach. Quick but limited:
Example:
render json: @user.as_json(only: [:id, :name, :email])
Gets messy fast when you need nested associations, computed fields, or different shapes for different endpoints.
2. Jbuilder
Template-based JSON rendering — .json.jbuilder files that look like view templates:
Example:
# app/views/api/v1/users/show.json.jbuilder
json.id @user.id
json.name @user.name
json.email @user.email
json.posts @user.posts do |post|
json.id post.id
json.title post.title
end
Familiar if you know ERB, but slower than object-based serializers and harder to test in isolation.
3. Active Model Serializers or jsonapi-serializer
Dedicated serializer classes — the clean, testable approach:
Setup:
bundle add jsonapi-serializer
Example:
# app/serializers/user_serializer.rb
class UserSerializer
include JSONAPI::Serializer
attributes :name, :email, :created_at
has_many :posts
attribute :full_name do |user|
"#{user.first_name} #{user.last_name}"
end
end
Example:
# In controller
render json: UserSerializer.new(@user).serializable_hash
Serializer classes are easy to test, reusable across endpoints, and keep JSON shaping out of controllers and views.
Authentication — Token-Based
Without sessions or cookies, API authentication is typically token-based. JWT is common; so is simple API key authentication:
Example:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
before_action :authenticate_request
private
def authenticate_request
token = request.headers["Authorization"]&.split(" ")&.last
payload = decode_token(token)
@current_user = User.find_by(id: payload["user_id"])
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
rescue JWT::DecodeError
render json: { error: "Invalid token" }, status: :unauthorized
end
def decode_token(token)
JWT.decode(token, Rails.application.credentials.secret_key_base).first
end
end
For more robust authentication, the devise-jwt gem integrates Devise with JWT tokens cleanly.
Versioning
API versioning is one of those decisions that’s much easier to bake in from the start than retrofit later. Namespace your controllers:
Example:
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users
resources :posts
end
end
end
Example:
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
def index
@users = User.all
render json: UserSerializer.new(@users).serializable_hash
end
def show
@user = User.find(params[:id])
render json: UserSerializer.new(@user).serializable_hash
end
end
end
end
When breaking changes are necessary, add a v2 namespace. Existing clients on v1 are unaffected.
Error Handling
Consistent error responses make APIs predictable for clients:
Example:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordNotFound do |e|
render json: { error: e.message }, status: :not_found
end
rescue_from ActiveRecord::RecordInvalid do |e|
render json: {
error: "Validation failed",
details: e.record.errors.as_json
}, status: :unprocessable_entity
end
rescue_from ActionController::ParameterMissing do |e|
render json: { error: e.message }, status: :bad_request
end
end
Centralized error handling means controllers stay clean and every endpoint returns structured errors without repetitive rescue blocks.
Pro-Tip: Add
Accept: application/jsonandContent-Type: application/jsonheader enforcement to yourbefore_actionstack, and return a clear error if those headers are missing. API clients that send the wrong content type cause confusing parse errors; failing fast with a clear message saves debugging time on both sides of the wire.
Testing API Endpoints
Request specs (not controller specs) are the right level for testing APIs:
Example:
# spec/requests/api/v1/users_spec.rb
RSpec.describe "GET /api/v1/users/:id" do
let(:user) { create(:user) }
let(:token) { generate_jwt(user) }
it "returns the user" do
get "/api/v1/users/#{user.id}",
headers: { "Authorization" => "Bearer #{token}" }
expect(response).to have_http_status(:ok)
expect(json_response["data"]["attributes"]["email"]).to eq(user.email)
end
it "returns 401 without a token" do
get "/api/v1/users/#{user.id}"
expect(response).to have_http_status(:unauthorized)
end
end
Request specs exercise the full middleware stack — routing, authentication, serialization — which is the right level of confidence for an API endpoint.
Conclusion
Rails API mode isn’t a stripped-down Rails — it’s Rails with the right defaults for services that speak JSON. You keep everything that matters: ActiveRecord, migrations, concerns, background jobs, mailers, credentials. You drop what doesn’t apply. The serialization and versioning choices you make early have the biggest impact on long-term maintainability. Get those right and Rails API mode is one of the most productive environments available for building backend services.
FAQs
Q1: Should I use Rails API mode or a lighter framework like Sinatra?
Rails API mode for anything with business logic, ActiveRecord, background jobs, or teams. Sinatra for genuinely simple services with few endpoints and minimal dependencies. Rails’s ecosystem and conventions pay off quickly as complexity grows.
Q2: How do I handle CORS in Rails API mode?
Add the rack-cors gem and configure allowed origins in an initializer. rails new --api includes a commented-out CORS configuration in config/initializers/cors.rb — uncomment and configure it for your frontend’s origin.
Q3: Can Rails API mode serve both JSON and HTML?
Not easily from the same controller. If you need both, a full-stack Rails app with respond_to blocks is cleaner. API mode is committed to JSON responses; adding HTML rendering requires re-adding middleware and view infrastructure.
Q4: What’s the best way to document a Rails API?
RSwag (Swagger/OpenAPI integration with RSpec) generates interactive documentation from your request specs. It’s the most maintainable approach because documentation and tests are colocated and stay in sync automatically.
Q5: How do I handle file uploads in an API?
Use Active Storage with direct uploads. The browser uploads directly to S3 (or your storage service) and sends the signed blob reference to your API. Your API attaches the blob to the record. No file data flows through your API server — only references.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀