/ Tags: RAILS / Categories: RAILS

Turbo Native — Wrapping Your Rails App in an iOS and Android Shell

A native app is three codebases: the web app, the iOS app, and the Android app, each reimplementing the same screens and drifting apart. Turbo Native takes a different position — your Rails app is the app, rendered in a native navigation shell, with native screens only where they earn their place. For a lot of products that’s the right trade, and the ones where it isn’t are usually identifiable in advance.

What It Actually Is


A thin native wrapper hosting a WKWebView (iOS) or WebView (Android), driven by a native navigation stack. Tap a link, the wrapper intercepts it, pushes a new native view controller, and loads the URL inside it.

The result is that transitions, the navigation bar, the back gesture, and the tab bar are all native. The content is your existing Rails views. Users get platform-correct chrome; you ship one set of screens.

What you keep: your controllers, views, forms, sessions, authorization, and deploy pipeline. A bug fix ships without an App Store review.

What you don’t get: offline support, genuinely native scrolling performance on complex screens, and access to platform APIs without writing native code for them.

The Rails Side


Surprisingly little changes. Turbo Native identifies itself in the user agent, and you branch on it.

Example:

# app/controllers/concerns/turbo_native_detection.rb
module TurboNativeDetection
  extend ActiveSupport::Concern

  included do
    helper_method :turbo_native_app?
  end

  private

  def turbo_native_app?
    request.user_agent.to_s.include?("Turbo Native")
  end
end
class ApplicationController < ActionController::Base
  include TurboNativeDetection

  layout -> { turbo_native_app? ? "turbo_native" : "application" }
end

Example:

<%# app/views/layouts/turbo_native.html.erb %>
<!DOCTYPE html>
<html>
  <head>
    <%= csrf_meta_tags %>
    <%= stylesheet_link_tag "turbo_native" %>
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  </head>
  <body class="turbo-native">
    <%= yield %>
  </body>
</html>

The native layout drops your web header, footer, and navigation — the shell provides those. That’s most of the work: your content templates are reused as-is, and only the chrome differs.

Path Configuration


The mechanism that makes the shell feel native rather than like a browser. A JSON file, served by Rails, tells the app how to present each URL pattern.

Example:

# config/routes.rb
get "configurations/ios", to: "configurations#ios"
class ConfigurationsController < ApplicationController
  skip_before_action :authenticate_user!

  def ios
    render json: {
      settings: { screenshots_enabled: false },
      rules: [
        {
          patterns: ["/new$", "/edit$"],
          properties: { context: "modal", pull_to_refresh_enabled: false }
        },
        {
          patterns: ["/orders/\\d+$"],
          properties: { presentation: "push" }
        },
        {
          patterns: ["/settings$"],
          properties: { view_controller: "settings", presentation: "replace" }
        },
        {
          patterns: ["/sign_out$"],
          properties: { presentation: "clear_all" }
        }
      ]
    }
  end
end

Serving this from Rails rather than bundling it in the app is the important choice. It means you can change how a screen is presented — make a form a modal, route a URL to a native screen — with a deploy, not an App Store submission.

context: "modal" on /new and /edit is the single highest-impact rule: forms slide up as sheets with a Cancel button, which is what users expect on mobile and what makes the app stop feeling like a website.

Native Screens Where They Matter


The pragmatic position is a hybrid: web for the 90% that’s forms and lists, native for the handful of screens where the web falls short.

Candidates that genuinely justify native code:

  • Camera and photo capture — file inputs work but feel wrong
  • Maps — native map SDKs beat an embedded web map substantially
  • Push notification handling and permission prompts
  • Biometric authentication
  • The primary tab bar — always native, it’s the app’s skeleton
  • Anything with heavy gesture interaction — drag-to-reorder, swipe actions

Route to them with view_controller in the path configuration and implement the screen natively. The rest of the app doesn’t need to know.

Handling Forms and Redirects


Turbo Native follows the same semantics as Turbo Drive, with one addition worth knowing: turbo_native responses can dismiss a modal and refresh the screen underneath.

Example:

class OrdersController < ApplicationController
  def create
    @order = current_account.orders.build(order_params)

    if @order.save
      if turbo_native_app?
        # Dismisses the modal and refreshes the previous screen
        redirect_to recede_path(order_path(@order)), status: :see_other
      else
        redirect_to @order, notice: "Order created"
      end
    else
      render :new, status: :unprocessable_entity
    end
  end
end

The navigation helpers — recede_to, resume_at, refresh_historical_location — control the native stack from Rails. recede pops back and refreshes; resume dismisses without refreshing. Getting these right is what makes “submit a form in a modal and see the list update” work correctly, and it’s the detail that most distinguishes a polished Turbo Native app from a rough one.

Authentication


Sessions work over cookies as normal, but a native app can’t show your web sign-in page and have it feel right.

The usual pattern: a native sign-in screen posts credentials to a Rails endpoint, receives the session cookie, and the web view inherits it from the shared cookie store.

Example:

class Api::SessionsController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    user = User.authenticate_by(email: params[:email], password: params[:password])

    if user
      sign_in(user)
      render json: { status: "ok" }
    else
      render json: { error: "Invalid credentials" }, status: :unauthorized
    end
  end
end

Handle expiry explicitly — a 401 from the web view should trigger the native sign-in screen rather than rendering your web login page inside the shell.

Pro-Tip: Build the path configuration and the native shell before writing any native screens, and ship that to TestFlight in week one. The temptation is to start with the screens you know need to be native, but the thing you actually need to learn early is whether your existing views survive being in a web view at all — and they usually don’t, in specific and fixable ways. Fixed headers fight the native nav bar, 100vh is wrong when the keyboard appears, :hover states stick after a tap, and any hard-coded page width breaks on a small screen. Every one of those is a CSS fix in your Rails app, and finding all of them in the first week is a day of work. Finding them after you’ve built four native screens is a rewrite of your stylesheet with four native screens depending on it.

When Not to Use It


Be honest about the boundary:

  • Offline-first products. There’s no meaningful offline story. If your users work on job sites without signal, this is the wrong architecture.
  • Heavy real-time interaction — collaborative editing, games, anything with sustained 60fps requirements.
  • Media-centric apps where scroll performance through thousands of images is the core experience.
  • Deep platform integration — widgets, Live Activities, complex Share extensions, background processing. Possible, but you’re writing so much native code that the premise weakens.
  • A team with no native experience and no appetite to acquire any. You will need to build, sign, and ship the wrapper, and debug platform-specific issues.

The sweet spot is the large category of CRUD-shaped business software: forms, lists, dashboards, workflows. For those, Turbo Native gets you a legitimate app for a fraction of the cost of two native codebases.

Conclusion


Turbo Native keeps your Rails app as the single source of screens and wraps it in native navigation, which for form-and-list products is a genuinely good trade — one codebase, instant deploys for content changes, native chrome where users notice it. Serve the path configuration from Rails so presentation rules ship without a review cycle, use context: "modal" on your new and edit routes, and get the navigation helpers right so modals dismiss and refresh correctly. Go native only for camera, maps, notifications, biometrics, and the tab bar. And put the shell in front of your existing views in week one — the CSS problems you’ll find are all fixable, and they’re much cheaper to fix before anything depends on them.

FAQs


Q1: Do I need to know Swift and Kotlin?
Some. The starter apps are small and mostly configuration, but you’ll write native code for authentication, push notifications, and any native screens, and you’ll need to handle signing and store submission.

Q2: Will Apple reject a Turbo Native app?
Not for using a web view, provided the app delivers real functionality and native navigation. Rejections in this space are typically for apps that are a bare web view with no native behaviour — which is exactly what path configuration and native screens avoid.

Q3: How do push notifications work?
Natively. Register the device token with your Rails app, send via APNs/FCM from a background job, and have the tap handler navigate the web view to the relevant URL.

Q4: Can I use Turbo Streams and Action Cable?
Yes — they work inside the web view exactly as on the web, including broadcast updates. This is one of the nicer parts: real-time features carry over with no extra work.

Q5: How do I debug the web view?
Safari’s Web Inspector attaches to the iOS simulator’s web view, and Chrome DevTools attaches to Android’s. Both work the same as debugging any page, which makes the web half of the app far easier to work on than native UI.

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