Integrating Ruby on Rails with React Native using Expo
[ Mobile Development ]

Integrating Ruby on Rails with React Native using Expo

Build full-stack mobile apps with Rails backend and Expo frontend. Master JWT authentication, API design, and data synchronization between Rails and React Native.

→ featured
→ essential
→ timely
By Paul Badarau 15 min read
[ Article Content ]
Share this article:
P
Paul Badarau
Author

Integrating Ruby on Rails with React Native using Expo

Primary Keyword: Rails React Native integration
Meta Description: Build full-stack mobile apps with Rails backend and Expo frontend. Master JWT authentication, API design, and data synchronization between Rails and React Native.


The Full-Stack Opportunity You're Probably Sleeping On

You know Rails. You can build a web API in your sleep. You've shipped production apps with Django, or Laravel, or Elixir. Now you want to build mobile.

Most teams choose: "Let's hire native iOS and Android developers" or "Let's use a cross-platform framework like React Native."

What fewer teams realize: you already have the skills for both. If you can build a Rails API, you can ship a production mobile app with Expo. The backend stays Rails. The frontend becomes React Native.

This is the full-stack opportunity: you own the entire product, from database to phone. No handoff to mobile specialists. No managing three codebases. One product, two frontends (web and mobile), one backend you understand completely.

Why this matters: We've shipped mobile products using Expo + Rails. The time to first release is 6–8 weeks, not 6 months. Deployment is straightforward. Bug fixes ship to both platforms simultaneously. Feature development is faster because the team understands the whole system.

By the end of this guide, you'll understand:

  • How to structure a Rails API for mobile clients.
  • Authentication strategies (JWT vs. sessions) and which to choose.
  • How to handle data fetching and caching in Expo.
  • How to manage errors and offline resilience.
  • How to ship updates without app store delays.

Architecture: Rails API + Expo Frontend

The architecture is simple: Rails handles everything the database needs (auth, validation, business logic). Expo handles everything the phone needs (UI, offline support, native features).

┌──────────────────┐
│   Rails API      │
│   - Database     │
│   - Auth         │
│   - Business     │
│     logic        │
└──────────────────┘
         ↕
    HTTP/REST
         ↕
┌──────────────────┐
│  Expo Frontend   │
│  - UI (React)    │
│  - Caching       │
│  - Offline       │
│  - Native APIs   │
└──────────────────┘

The contract between them is HTTP. Rails serves JSON. Expo consumes it.

Setting Up Rails for Mobile

Your Rails API should treat mobile clients like any other client. No special-casing. Just good API design.

1. Enable CORS for your frontend domain:

# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost:8081', 'example.com'  # Expo dev server, production domain
    resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete]
  end
end

Add the rack-cors gem if you haven't:

# Gemfile
gem 'rack-cors'

2. Structure API routes cleanly:

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :users, only: [:show, :update]
      resources :posts, only: [:index, :show, :create]
      resources :comments, only: [:create, :destroy]
      
      post '/auth/login', to: 'auth#login'
      post '/auth/logout', to: 'auth#logout'
      post '/auth/refresh', to: 'auth#refresh'
    end
  end
end

Versioning is important. When you need to change the API, v1 still works for older app versions.

3. Standardize error responses:

Every error should have the same shape:

# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController
  def render_error(message, status = :bad_request, details = {})
    render json: {
      error: {
        message: message,
        status: status,
        details: details
      }
    }, status: status
  end
end

# In your controller
class Api::V1::PostsController < Api::V1::BaseController
  def create
    post = Post.new(post_params)
    if post.save
      render json: post, status: :created
    else
      render_error('Failed to create post', :unprocessable_entity, post.errors)
    end
  end
end

Mobile clients need consistent error handling. Same shape, always.


Authentication: JWT vs. Sessions

This is the first decision: how do you authenticate between mobile and Rails?

Sessions (The Traditional Approach)

Rails loves sessions. They're secure, they're built-in, and they "just work" for web browsers.

For mobile, they're awkward.

How sessions work:

  1. User logs in with username/password.
  2. Rails stores the session in Redis or a database.
  3. Rails sends a Set-Cookie header.
  4. The browser stores the cookie and sends it on every request.

The problem for mobile:

Cookies are designed for browsers. Mobile HTTP clients can handle cookies, but:

  • They're opaque to the app. You can't inspect or manage them easily.
  • They're tied to domain and path. Mobile clients are more flexible.
  • They're not ideal for offline or cross-device scenarios.

Session-based auth can work for mobile, but it's fighting the medium.

JWT (The Mobile-Friendly Approach)

JWT (JSON Web Tokens) is an alternative. Instead of storing state on the server, you sign a token that the client carries.

How JWT works:

  1. User logs in with username/password.
  2. Rails creates a JWT token (a signed JSON object) containing user info.
  3. Rails returns the token to the client.
  4. The client stores the token (in SecureStore, not localStorage).
  5. The client sends the token in the Authorization header on every request.

Advantages for mobile:

  • The client fully controls the token. You can refresh it, clear it, store it securely.
  • No cookies required. Works with mobile HTTP clients natively.
  • Stateless. Rails doesn't need to store anything (though you might want a blacklist for logout).
  • Works offline (sort of—your API still needs to be reachable for permission checks).

We recommend JWT for mobile + Rails. It fits the model better.

Implementing JWT in Rails + Expo

1. Set up JWT in Rails:

Add the gem:

# Gemfile
gem 'jwt'

Create an auth controller:

# app/controllers/api/v1/auth_controller.rb
class Api::V1::AuthController < Api::V1::BaseController
  skip_before_action :authenticate_request!, only: [:login]

  def login
    user = User.find_by(email: auth_params[:email])
    
    if user&.authenticate(auth_params[:password])
      token = JwtToken.encode(user_id: user.id)
      render json: {
        user: UserSerializer.new(user),
        token: token
      }
    else
      render_error('Invalid credentials', :unauthorized)
    end
  end

  def logout
    # Optional: add token to blacklist if you want to revoke immediately
    # For now, token expires naturally
    render json: { message: 'Logged out' }
  end

  def refresh
    current_user  # This validates the token
    token = JwtToken.encode(user_id: current_user.id)
    render json: { token: token }
  end

  private

  def auth_params
    params.require(:auth).permit(:email, :password)
  end
end

Create a JWT utility:

# lib/jwt_token.rb
class JwtToken
  SECRET = Rails.application.secrets.secret_key_base

  def self.encode(payload, exp = 7.days.from_now)
    payload[:exp] = exp.to_i
    JWT.encode(payload, SECRET, 'HS256')
  end

  def self.decode(token)
    JWT.decode(token, SECRET, true, { algorithm: 'HS256' })[0]
  rescue JWT::DecodeError, JWT::ExpiredSignature
    nil
  end
end

Add middleware to authenticate requests:

# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController
  before_action :authenticate_request!

  protected

  def authenticate_request!
    token = request.headers['Authorization']&.split(' ')&.last
    payload = JwtToken.decode(token)

    if payload
      @current_user = User.find(payload['user_id'])
    else
      render_error('Unauthorized', :unauthorized)
    end
  end

  def current_user
    @current_user
  end
end

2. Set up JWT in Expo:

Store the token securely:

// lib/auth.ts
import * as SecureStore from 'expo-secure-store';

export async function saveToken(token: string) {
  await SecureStore.setItemAsync('authToken', token);
}

export async function getToken() {
  return await SecureStore.getItemAsync('authToken');
}

export async function clearToken() {
  await SecureStore.deleteItemAsync('authToken');
}

Create an API client that injects the token:

// lib/api.ts
import axios from 'axios';
import { getToken } from './auth';

const API_URL = 'https://api.example.com/api/v1';

const apiClient = axios.create({
  baseURL: API_URL,
});

// Inject token on every request
apiClient.interceptors.request.use(async (config) => {
  const token = await getToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Handle token expiration
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      // Token expired, clear it and redirect to login
      await clearToken();
      // Trigger login screen (your app routing logic here)
    }
    return Promise.reject(error);
  }
);

export default apiClient;

3. Login in Expo:

// screens/LoginScreen.tsx
import { useState } from 'react';
import { View, TextInput, Button, Text } from 'react-native';
import apiClient from '@/lib/api';
import { saveToken } from '@/lib/auth';
import { useRouter } from 'expo-router';

export default function LoginScreen() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const router = useRouter();

  const handleLogin = async () => {
    try {
      const response = await apiClient.post('/auth/login', {
        auth: { email, password },
      });
      
      await saveToken(response.data.token);
      router.replace('(tabs)/home');
    } catch (err) {
      setError('Invalid email or password');
    }
  };

  return (
    <View style={{ padding: 20 }}>
      <TextInput
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        keyboardType="email-address"
      />
      <TextInput
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      {error && <Text style={{ color: 'red' }}>{error}</Text>}
      <Button title="Login" onPress={handleLogin} />
    </View>
  );
}

Refresh Tokens (Optional, But Recommended)

For extra security, use short-lived access tokens + refresh tokens.

In Rails:

def login
  user = User.find_by(email: auth_params[:email])
  
  if user&.authenticate(auth_params[:password])
    access_token = JwtToken.encode(
      { user_id: user.id, type: 'access' },
      1.hour.from_now
    )
    refresh_token = JwtToken.encode(
      { user_id: user.id, type: 'refresh' },
      7.days.from_now
    )
    
    render json: {
      user: UserSerializer.new(user),
      accessToken: access_token,
      refreshToken: refresh_token
    }
  else
    render_error('Invalid credentials', :unauthorized)
  end
end

def refresh
  current_user
  new_access_token = JwtToken.encode(
    { user_id: current_user.id, type: 'access' },
    1.hour.from_now
  )
  render json: { accessToken: new_access_token }
end

In Expo:

Store both tokens:

export async function saveTokens(accessToken: string, refreshToken: string) {
  await SecureStore.setItemAsync('accessToken', accessToken);
  await SecureStore.setItemAsync('refreshToken', refreshToken);
}

export async function getAccessToken() {
  return await SecureStore.getItemAsync('accessToken');
}

export async function getRefreshToken() {
  return await SecureStore.getItemAsync('refreshToken');
}

Handle token refresh automatically:

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      const refreshToken = await getRefreshToken();
      try {
        const response = await apiClient.post('/auth/refresh', {
          token: refreshToken,
        });
        const newAccessToken = response.data.accessToken;
        await SecureStore.setItemAsync('accessToken', newAccessToken);
        
        // Retry original request with new token
        error.config.headers.Authorization = `Bearer ${newAccessToken}`;
        return apiClient(error.config);
      } catch (refreshError) {
        // Refresh failed, logout
        await clearTokens();
      }
    }
    return Promise.reject(error);
  }
);

Data Fetching and Caching

Mobile networks are slow and unreliable. Your data fetching strategy matters.

Strategy: SWR or React Query

Both SWR (stale-while-revalidate) and React Query are excellent for mobile. They handle caching, refetching, and offline resilience.

We recommend SWR for its simplicity:

// hooks/useApi.ts
import useSWR from 'swr';
import apiClient from '@/lib/api';

export function useApi<T>(url: string | null) {
  const { data, error, isLoading, mutate } = useSWR<T>(
    url,
    (url) => apiClient.get(url).then((res) => res.data),
    {
      revalidateOnFocus: false,  // Don't refetch when app comes to foreground
      dedupingInterval: 60000,   // Cache for 1 minute
      focusThrottleInterval: 300000,  // Only refetch every 5 minutes
    }
  );

  return {
    data,
    isLoading,
    error: error?.message,
    mutate,  // Manual refresh
  };
}

Use it in a screen:

// screens/PostsScreen.tsx
import { useApi } from '@/hooks/useApi';
import { FlatList, Text, View, ActivityIndicator } from 'react-native';

interface Post {
  id: number;
  title: string;
  body: string;
}

export default function PostsScreen() {
  const { data: posts, isLoading, error } = useApi<Post[]>('/posts');

  if (isLoading) {
    return <ActivityIndicator />;
  }

  if (error) {
    return <Text>Error: {error}</Text>;
  }

  return (
    <FlatList
      data={posts}
      renderItem={({ item }) => (
        <View>
          <Text style={{ fontSize: 16, fontWeight: 'bold' }}>{item.title}</Text>
          <Text>{item.body}</Text>
        </View>
      )}
      keyExtractor={(post) => post.id.toString()}
    />
  );
}

Pagination and Infinite Lists

For large lists, implement pagination:

# app/controllers/api/v1/posts_controller.rb
def index
  posts = Post.order(created_at: :desc)
              .limit(20)
              .offset((params[:page].to_i - 1) * 20)
  
  render json: {
    posts: posts,
    page: params[:page].to_i,
    total_pages: (Post.count / 20.0).ceil
  }
end

In Expo, use SWR's infinite data feature:

// hooks/useInfinitePosts.ts
import useSWRInfinite from 'swr/infinite';
import apiClient from '@/lib/api';

export function useInfinitePosts() {
  const getKey = (pageIndex: number) => {
    return `/posts?page=${pageIndex + 1}`;
  };

  const { data, error, size, setSize } = useSWRInfinite(
    getKey,
    (url) => apiClient.get(url).then((res) => res.data),
    { revalidateFirstPage: false }
  );

  const posts = data?.flatMap((page) => page.posts) || [];
  const isLoading = !data;
  const hasMore = data?.[data.length - 1]?.page < data?.[data.length - 1]?.total_pages;

  const loadMore = () => setSize(size + 1);

  return { posts, isLoading, hasMore, error, loadMore };
}

Use it:

<FlatList
  data={posts}
  renderItem={({ item }) => <PostItem post={item} />}
  keyExtractor={(post) => post.id.toString()}
  onEndReached={() => hasMore && loadMore()}
  onEndReachedThreshold={0.5}
/>

Error Handling and Resilience

Expect Network Failures

Assume your network will fail. Design for it.

In Rails, provide meaningful error responses:

# app/controllers/api/v1/base_controller.rb
rescue_from ActiveRecord::RecordNotFound do
  render_error('Resource not found', :not_found)
end

rescue_from StandardError do |e|
  Sentry.capture_exception(e)  # Log to error tracking
  render_error('Something went wrong', :internal_server_error)
end

In Expo, handle errors gracefully:

async function fetchWithRetry(url: string, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await apiClient.get(url);
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      
      // Exponential backoff
      const delay = Math.pow(2, i) * 1000;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

Offline Support

Expo has NetInfo for detecting connectivity:

// hooks/useNetInfo.ts
import { useEffect, useState } from 'react';
import NetInfo from '@react-native-community/netinfo';

export function useNetInfo() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((state) => {
      setIsOnline(state.isConnected ?? false);
    });

    return unsubscribe;
  }, []);

  return isOnline;
}

For data that needs to work offline, consider local caching:

// lib/offlineStorage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';

export async function cacheData(key: string, data: any) {
  await AsyncStorage.setItem(key, JSON.stringify(data));
}

export async function getCachedData(key: string) {
  const data = await AsyncStorage.getItem(key);
  return data ? JSON.parse(data) : null;
}

Then use it as a fallback:

export function useApi<T>(url: string | null) {
  const { data, error, isLoading } = useSWR(
    url,
    async (url) => {
      try {
        const response = await apiClient.get(url);
        await cacheData(url, response.data);
        return response.data;
      } catch (err) {
        // Try cache as fallback
        return await getCachedData(url);
      }
    }
  );

  return { data, isLoading, error };
}

Deployment and Updates

Deploying Rails

Rails deployment is standard. Use your favorite hosting (Heroku, AWS, DigitalOcean).

Key points:

  • HTTPS only. Mobile clients won't send credentials over HTTP.
  • Rate limiting. Mobile apps can be abused. Implement rate limiting.
  • Versioning. Keep v1 endpoints stable. Add v2 for breaking changes.

Deploying Expo Apps

For development and testing, Expo Go is perfect. For production:

EAS Build:

eas build --platform all --profile production

This outputs binaries for TestFlight (iOS) and Google Play (Android).

OTA Updates (Over-The-Air):

For bug fixes and non-native changes, use Expo's update service:

eas update --branch production

This deploys JavaScript changes without going through the app store.


Real Example: A Task Management App

Let's tie it together. A simple task app: Rails backend, Expo frontend.

Rails API:

# app/models/task.rb
class Task < ApplicationRecord
  belongs_to :user
  validates :title, presence: true
end

# app/controllers/api/v1/tasks_controller.rb
class Api::V1::TasksController < Api::V1::BaseController
  def index
    tasks = current_user.tasks.order(created_at: :desc)
    render json: tasks
  end

  def create
    task = current_user.tasks.build(task_params)
    if task.save
      render json: task, status: :created
    else
      render_error('Failed to create task', :unprocessable_entity, task.errors)
    end
  end

  def update
    task = current_user.tasks.find(params[:id])
    if task.update(task_params)
      render json: task
    else
      render_error('Failed to update task', :unprocessable_entity, task.errors)
    end
  end

  def destroy
    task = current_user.tasks.find(params[:id])
    task.destroy
    render json: { message: 'Task deleted' }
  end

  private

  def task_params
    params.require(:task).permit(:title, :description, :completed)
  end
end

Expo Frontend:

// screens/TasksScreen.tsx
import { useApi } from '@/hooks/useApi';
import { View, FlatList, Text, Button, TextInput } from 'react-native';
import { useState } from 'react';
import apiClient from '@/lib/api';

interface Task {
  id: number;
  title: string;
  completed: boolean;
}

export default function TasksScreen() {
  const { data: tasks, mutate } = useApi<Task[]>('/tasks');
  const [newTitle, setNewTitle] = useState('');

  const handleCreateTask = async () => {
    try {
      await apiClient.post('/tasks', {
        task: { title: newTitle, completed: false },
      });
      setNewTitle('');
      mutate();  // Refetch tasks
    } catch (error) {
      console.error('Failed to create task', error);
    }
  };

  const handleToggleTask = async (task: Task) => {
    try {
      await apiClient.patch(`/tasks/${task.id}`, {
        task: { completed: !task.completed },
      });
      mutate();
    } catch (error) {
      console.error('Failed to update task', error);
    }
  };

  return (
    <View style={{ padding: 20 }}>
      <TextInput
        placeholder="New task..."
        value={newTitle}
        onChangeText={setNewTitle}
      />
      <Button title="Add Task" onPress={handleCreateTask} />
      
      <FlatList
        data={tasks}
        renderItem={({ item }) => (
          <View style={{ padding: 10, borderBottomWidth: 1 }}>
            <Text
              style={{
                textDecorationLine: item.completed ? 'line-through' : 'none',
              }}
            >
              {item.title}
            </Text>
            <Button
              title={item.completed ? 'Undo' : 'Done'}
              onPress={() => handleToggleTask(item)}
            />
          </View>
        )}
        keyExtractor={(task) => task.id.toString()}
      />
    </View>
  );
}

Done. A full-stack task app in ~100 lines of code across both platforms.


Bringing It All Together

Building mobile with Rails + Expo is pragmatic. You leverage your existing skills, you own the full stack, and you ship faster.

The key points:

  1. Structure your Rails API well. CORS, versioning, consistent error responses.
  2. Use JWT for mobile authentication. It's stateless and client-friendly.
  3. Cache aggressively on the frontend. Mobile networks are slow.
  4. Handle errors and offline gracefully. Users will lose connection.
  5. Use EAS Build and Expo Updates. Deployment becomes simple.

Start with authentication (JWT). Then build a simple feature end-to-end (list items, create item). Then iterate.

Mobile development doesn't require learning Swift or Kotlin. If you know Rails and JavaScript, you already know most of what you need.

Have you built mobile with Rails? What patterns worked well? Share your experience on Twitter or LinkedIn—we'd love to hear how your team is shipping.


Further Reading

[ Let's Build Together ]

Ready to transform your
business with software?

From strategy to implementation, we craft digital products that drive real business outcomes. Let's discuss your vision.

Related Topics:
Rails React Native Expo JWT mobile API 2025