← Back to Blog

Building a Native Mobile API on an Existing Rails Application

Building a Native Mobile API on an Existing Rails Application — Antarr Byrd
Antarr Byrd·2026-03-02
Ruby on RailsAPIAndroid
Share

Ruby on Rails is a great tool for building web applications but it can also serve as the backend for native mobile applications. I recently needed to build a REST API for a native Android app on top of an existing Rails application for civic engagement. The web app already uses Devise for authentication, Active Storage for avatars, and polymorphic associations for upvotes and follows. In this post I will walk you through how I structured the API layer to serve the mobile client without modifying the existing web controllers.

Setting Up the API Namespace

Rather than adding respond_to :json blocks to the existing web controllers, I created a dedicated namespace. This keeps the mobile API completely separate from the web app's Turbo Stream responses and Devise redirects.

config/routes.rb

namespace :api do
  namespace :native do
    post   'session', to: 'sessions#create'
    delete 'session', to: 'sessions#destroy'
    get    'feed',    to: 'feed#index'
    # additional routes for bills, politicians, discussions, etc.
  end
end

All native endpoints live under /api/native/ with their own controllers and serialization logic.

The Base Controller

The first step is to create a base controller that all API controllers will inherit from. Since mobile clients don't use CSRF tokens, we skip that verification. Authentication checks that the user is signed in via Devise and returns a JSON error if not.

app/controllers/api/native/base_controller.rb

module Api
  module Native
    class BaseController < ApplicationController
      skip_before_action :verify_authenticity_token
      before_action :require_native_login

      private

      def require_native_login
        return if user_signed_in?
        render json: { error: 'Authentication required' }, status: :unauthorized
      end
    end
  end
end

Session Authentication

The next step is to handle login. The SessionsController validates credentials and signs the user in using Devise's sign_in method. The Android client stores the session cookie and sends it with subsequent requests.

app/controllers/api/native/sessions_controller.rb

module Api
  module Native
    class SessionsController < ApplicationController
      skip_before_action :verify_authenticity_token

      def create
        user = User.find_by(email: params[:email].to_s.strip.downcase)

        if user.nil? || !user.valid_password?(params[:password].to_s)
          render json: { error: 'Invalid email or password' }, status: :unauthorized
          return
        end

        sign_in(:user, user)
        render json: { user: { id: user.id, email: user.email, username: user.username } }
      end

      def destroy
        sign_out(current_user) if user_signed_in?
        head :no_content
      end
    end
  end
end

You should also check for unconfirmed and locked accounts. I omitted those checks here for brevity.

Serializing Data for Mobile

I chose not to use a serializer gem like active_model_serializers or jsonapi-serializer. Instead each controller has private methods that return plain hashes. This lets each endpoint return exactly what the screen needs.

app/controllers/api/native/feed_controller.rb

def serialize_bill(bill, upvoted_ids, followed_ids)
  decorator = bill.decorate
  {
    id: bill.slug,
    identifier: bill.identifier,
    title: bill.title,
    summary: bill.summary,
    chamber_label: decorator.chamber_label,
    status: bill.latest_action_description,
    upvotes_count: bill.upvotes_count,
    comments_count: bill.comments_count,
    upvoted: upvoted_ids.include?(bill.id),
    following: followed_ids.include?(bill.id)
  }
end

Batching User State Lookups

One challenge with list endpoints is checking whether the current user has upvoted or followed each item. Doing this per-item creates N+1 query problems. I batch these lookups by collecting all the IDs up front and using a Set for O(1) lookups.

app/controllers/api/native/feed_controller.rb

def index
  bills = recommendations_bills(page)
  bill_ids = bills.map(&:id)

  upvoted_ids  = current_user.upvotes
    .where(upvotable_type: 'OpenStates::Bill', upvotable_id: bill_ids)
    .pluck(:upvotable_id).to_set

  followed_ids = current_user.followings
    .where(followable_type: 'OpenStates::Bill', followable_id: bill_ids)
    .pluck(:followable_id).to_set

  render json: {
    items: bills.map { |bill| serialize_bill(bill, upvoted_ids, followed_ids) },
    meta: { current_page: bills.current_page, total_pages: bills.total_pages, has_next: !bills.last_page? }
  }
end

This replaces what would be 40 queries with 2.

Handling Idempotent Actions

Mobile clients often retry requests due to network issues. A user might tap "upvote" twice before the first response comes back. To handle this I use find_or_create_by! and rescue RecordNotUnique so the endpoint always returns the same response regardless of how many times it is called.

app/controllers/api/native/bills_controller.rb

def upvote
  current_user.upvotes.find_or_create_by!(upvotable: @bill)
  @bill.reload
  render json: { upvotes_count: @bill.upvotes_count, upvoted: true }
rescue ActiveRecord::RecordNotUnique
  @bill.reload
  render json: { upvotes_count: @bill.upvotes_count, upvoted: true }
end

I use this same pattern for follows, petition signatures, and votes across the API.

Testing

Each controller has a corresponding RSpec file. I test that every endpoint returns 401 when unauthenticated and verify the expected response structure when authenticated.

spec/controllers/api/native/bills_controller_spec.rb

RSpec.describe Api::Native::BillsController do
  let(:user) { create(:user) }
  let(:bill) { create(:open_states_bill) }

  describe 'POST #upvote' do
    context 'when unauthenticated' do
      it 'returns 401' do
        post :upvote, params: { id: bill.slug }, format: :json
        expect(response).to have_http_status(:unauthorized)
      end
    end

    context 'when authenticated' do
      before { sign_in user }

      it 'creates an upvote and returns updated count' do
        expect {
          post :upvote, params: { id: bill.slug }, format: :json
        }.to change { bill.reload.upvotes_count }.by(1)
        json = JSON.parse(response.body)
        expect(json['upvoted']).to be true
      end
    end
  end
end

What I Shipped

The API covers 17 controllers in total: sessions, feed, bill detail, search, politicians, discussions, questions, petitions, conversations, messages, notifications, elections, ballots, settings, and users. The full feature set of the web app is available to the native client.

The approach is straightforward. A dedicated namespace keeps the API separate from the web app. A base controller handles authentication. Private serializer methods give each endpoint control over its response shape. Batched lookups prevent N+1 queries. And idempotent mutations handle the realities of mobile networking.

Please share any feedback to improve this or other postings.

Share