/ Tags: RAILS / Categories: RAILS

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:

  1. ApplicationController inherits from ActionController::API instead of ActionController::Base
  2. The middleware stack is trimmed — no cookie session, no flash, no browser caching
  3. 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/json and Content-Type: application/json header enforcement to your before_action stack, 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.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More