RUBY ON RAILS · 17 MIN READ ·

Hotwire Native voor Rails: bouw iOS- en Android-apps zonder React Native of Flutter

Hotwire Native voor Rails: lever native iOS- en Android-apps uit je Rails views met bridge components, path configuration en zonder React Native.

Hotwire Native voor Rails: bouw iOS- en Android-apps zonder React Native of Flutter

Een SaaS-oprichter huurde me afgelopen zomer in omdat zijn investeerders al twee jaar elk kwartaal dezelfde vraag stelden: “Wanneer lanceren jullie mobiel?” Zijn team bestond uit drie Rails-developers en één designer. De offerte voor een React Native-rebuild lag op vier maanden en ongeveer tweehonderdduizend euro. We lanceerden zowel een iOS- als een Android-app in negen werkdagen met Hotwire Native, waarbij we 95% van de bestaande Rails views hergebruikten. De App Store-review kwam binnen 38 uur goedgekeurd terug. Zijn seed-extension sloot de maand erna.

Ik doe inmiddels negentien jaar Rails, en Hotwire Native voor Rails is op dit moment de meest onderschatte hefboom die een klein team tot zijn beschikking heeft. Het is geen “responsive web-app die doet alsof hij native is.” Het zijn echte native shells (Swift op iOS, Kotlin op Android) die je bestaande Rails views in een WKWebView en WebView wikkelen, met native navigatie, échte native componenten waar dat moet, en de mogelijkheid om een UI-wijziging uit te rollen door simpelweg naar main te pushen. Deze post is het draaiboek: wanneer het de juiste keuze is, wanneer absoluut niet, en het bridge component-patroon dat van een “ingepakte web-app” iets maakt dat niet te onderscheiden is van een native build.

Wat Hotwire Native eigenlijk is

Hotwire Native is de evolutie van Turbo Native, in 2024 door 37signals herdoopt en herbouwd rond de Hotwire 8.0-release. Het idee is simpel en oud: een minimale native shell handelt navigatie, gestures en platform-chrome af; de daadwerkelijke schermen worden door je Rails-server als HTML gerenderd. Als een gebruiker op een link tikt, vraagt de shell aan Rails om de volgende pagina, Rails geeft HTML terug, en de shell toont die in een WKWebView (iOS) of WebView (Android) binnen een native navigation controller.

Wat Hotwire Native bovenop het originele Turbo Native heeft toegevoegd, is de path configuration: een JSON-bestand dat je Rails-app uitserveert en dat de native shells vertelt hoe ze elke URL moeten presenteren. Moet dit pad een modal zijn? Een bottom-sheet? Een tab? Moet er een native scherm gebruikt worden in plaats van de webview? Je beantwoordt het één keer, in Rails, en beide apps doen mee.

Het andere stuk zijn bridge components: een patroon waarmee je specifieke UI-fragmenten kunt vervangen door echte native code, terwijl de rest van de pagina gewoon HTML blijft. Camera-opnames, biometrische authenticatie, push-notificatie-prompts, native grafieken — alles wat echt native moet zijn, schrijf je één keer per platform en roep je aan vanuit elke Rails view met een Stimulus-controller en een data-attribuut. Al het andere blijft HTML.

Als je mijn stuk over Rails Stimulus controllers in productie hebt gelezen, weet je al hoe netjes Stimulus hierop aansluit. Bridge components zijn gewoon Stimulus-controllers die met native code praten in plaats van met de DOM.

Wanneer Hotwire Native de juiste keuze is

Dit is het stuk dat de meeste blogposts overslaan, dus laat ik specifiek zijn. Hotwire Native voor Rails is de juiste keuze als:

Je app in de kern CRUD is, verpakt in workflow. Dashboards, lijsten, formulieren, instellingen, message-threads, documentweergave, admin-tools. De meeste B2B-SaaS. De meeste interne apps. De meeste marketplaces.

Je team klein en Rails-vaardig is. Twee tot tien engineers die al Rails uitleveren. Je hebt niet de hoofden om een echte React Native- of Flutter-codebase te onderhouden, want dat is een volwaardige tweede codebase, wat de marketing er ook over zegt.

Je sneller features uitlevert dan de app stores kunnen reviewen. Als je wekelijks naar productie pusht, wil je niet dat elke UI-tweak een TestFlight-submission en een Play Console-uitrol vereist.

Je gebruikers niet leven en sterven bij 60fps-animaties. Hotwire Native-scrolling is uitstekend. Hotwire Native voor gesture-gedreven animaties is niet wat je wilt. Bouw je TikTok, ga dan native. Bouw je een B2B-projecttracker, dan ben je prima af.

Het is de verkeerde keuze als je een game bouwt, een creatieve tool met continue gestures, een video-editor, of iets dat urenlang offline moet werken. Daarvoor bouw je native. Voor de andere 80% van de mobiele apps die mensen daadwerkelijk uitleveren, bespaart Hotwire Native je een junior-salaris per jaar aan onderhoud.

Hotwire Native opzetten in Rails

De Rails-kant is bijna triviaal. Voeg de gem toe, genereer de path configuration, en serveer hem uit.

# Gemfile
gem "hotwire-native-rails", "~> 1.2"
bundle install
bin/rails generate hotwire_native:install

Die generator zet een path configuration-bestand neer op config/hotwire/native_path_configuration.json en regelt een controller-actie op /configurations/ios_v1.json en /configurations/android_v1.json. Je zou dit bestand moeten committen en zorgvuldig moeten versioneren — elke draaiende app-instance haalt het op bij launch.

{
  "settings": {
    "screenshots_enabled": true
  },
  "rules": [
    {
      "patterns": ["/new$", "/edit$"],
      "properties": {
        "context": "modal",
        "presentation": "default"
      }
    },
    {
      "patterns": ["/sign_in", "/sign_up"],
      "properties": {
        "context": "modal",
        "presentation": "fullscreen",
        "pull_to_refresh_enabled": false
      }
    },
    {
      "patterns": ["/projects/\\d+/messages/\\d+"],
      "properties": {
        "context": "default",
        "pull_to_refresh_enabled": false
      }
    },
    {
      "patterns": [".*"],
      "properties": {
        "context": "default",
        "presentation": "default"
      }
    }
  ]
}

Het pattern matching draait tegen het pad. New- en edit-formulieren worden modals — dat is het Rails-mentale model en het mapt perfect op de iOS modal sheet en de Android bottom sheet. Sign-in is een fullscreen modal, omdat je niet wilt dat gebruikers hem per ongeluk wegklikken. De rest van je app gebruikt de standaard push-navigatie.

Aan de Rails view-kant kun je zien of het request van een Hotwire Native-client komt en de rendering aanpassen. De gem voegt een helper toe:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_native_view_paths

  private

  def set_native_view_paths
    return unless hotwire_native_app?
    prepend_view_path Rails.root.join("app/views/native")
  end
end
<%# app/views/native/projects/show.html.erb %>
<%# Mobiel-geoptimaliseerd: grotere touch targets, geen sidebar-nav, simpelere header %>
<%= render "shared/native_header", project: @project %>

<div class="px-4 py-3 space-y-2">
  <%= render @project.recent_tasks %>
</div>

<%= link_to new_project_task_path(@project),
            class: "fixed bottom-6 right-6 rounded-full bg-indigo-600 p-4 shadow-lg",
            data: { turbo_frame: "_top" } do %>
  <%= heroicon "plus", class: "size-6 text-white" %>
<% end %>

Dat is het hele patroon. Dezelfde controller, dezelfde routes, dezelfde modellen. Webgebruikers zien de desktop-layout; de native shells zien de mobile-layout. Je kunt ook één gedeelde layout uitleveren als je je views al responsive hebt gebouwd — veel teams doen precies dat.

Het bridge component-patroon

De eerlijke zwakte van elke web-in-shell-aanpak is dat sommige dingen echt native moeten zijn: camera, biometrische authenticatie, push-notificaties, diepe haptics, native maps. Hotwire Native lost dit op met bridge components, en dat is het enkele concept dat deze aanpak van “goed genoeg” naar “voor de gebruiker niet te onderscheiden” tilt.

Hier is het contract. In je Rails view schrijf je een Stimulus-controller en plak je er een data-controller-attribuut op. Aan de native kant registreer je een bridge component met dezelfde naam. Wanneer de pagina laadt in een Hotwire Native-shell, neemt de bridge de rendering of het gedrag voor dat element over. Wanneer dezelfde pagina in een browser laadt, zorgt de Stimulus-controller voor een web-fallback.

<%# app/views/users/_avatar_upload.html.erb %>
<div data-controller="bridge--camera"
     data-bridge--camera-upload-url-value="<%= user_avatar_path(@user) %>"
     data-bridge--camera-mime-types-value='["image/jpeg","image/png"]'>
  <button data-action="bridge--camera#capture"
          type="button"
          class="rounded-md bg-indigo-600 px-4 py-2 text-white">
    Foto maken
  </button>
</div>
// app/javascript/controllers/bridge/camera_controller.js
import { BridgeComponent, BridgeElement } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "camera"
  static values = { uploadUrl: String, mimeTypes: Array }

  capture(event) {
    event.preventDefault()
    this.send("capture", { uploadUrl: this.uploadUrlValue, mimeTypes: this.mimeTypesValue }, (response) => {
      if (response.data.success) {
        Turbo.visit(this.uploadUrlValue, { action: "replace" })
      }
    })
  }
}

Op iOS registreer je een Swift-component die luistert naar capture, een UIImagePickerController opent, de resulterende afbeelding uploadt naar de uploadUrl, en antwoordt met success: true. Op Android doe je hetzelfde met ActivityResultContracts.PickVisualMedia. Beide shells krijgen één bestand per stuk.

// iOS/HotwireNativeApp/Bridge/CameraComponent.swift
import HotwireNative
import UIKit

final class CameraComponent: BridgeComponent {
  override class var name: String { "camera" }

  override func onReceive(message: Message) {
    guard message.event == "capture" else { return }
    presentCameraPicker(message: message)
  }

  private func presentCameraPicker(message: Message) {
    let picker = UIImagePickerController()
    picker.sourceType = .camera
    picker.delegate = self
    delegate.activeViewController.present(picker, animated: true)
  }
}

De gebruiker tikt op een knop. Op het web krijgt hij de standaard file picker. Op iOS de native camera-UI. Op Android de Material photo picker. Dezelfde Rails view, drie correcte ervaringen. Een senior mobile developer kan deze bridge components per platform in een middag implementeren, en je hergebruikt ze in elk scherm van de app.

De workflow in de praktijk

De meeste teams die ik begeleid, leveren uiteindelijk uit in dit ritme:

Een nieuwe feature start als een Rails view die werkt op het desktop-web. Je bouwt hem normaal — controllers, models, ERB, Stimulus, Turbo Frames. Zodra hij in de browser werkt, laad je dezelfde URL in de iOS-simulator en de Android-emulator. Beide shells bestaan al; beide weten al hoe ze dat pad moeten renderen dankzij de path configuration. De view verschijnt, mobiel gestyled dankzij Tailwind responsive classes, met native navigatie er omheen. Meestal pas je touch targets aan, verberg je een sidebar, en lever je uit.

Als je iets tegenkomt dat native moet zijn — image capture, biometric unlock, push permission, een native picker — voeg je één keer een bridge component toe. De tweede keer dat je die capability ergens in de app nodig hebt, voeg je alleen nog het data-attribuut toe. Geen tweede feature-ticket per platform.

App Store-updates gebeuren per kwartaal of minder. De native shells zijn klein — meestal onder de vijfhonderd regels Swift of Kotlin per platform. Ze veranderen zelden. Het week-tot-week feature-werk gaat allemaal door je normale Rails-deploy-pipeline, wat bij de meeste van mijn klanten een goed afgestelde Kamal 2-setup is.

Push-notificaties lopen nog steeds via APNs en FCM, maar je triggert ze vanuit Rails met de rpush gem of rechtstreeks via de Apple- en Google-API’s. Authenticatie gebruikt je bestaande session-cookie — zowel WKWebView als de Android WebView delen cookies met de shell, wat betekent dat een gebruiker die via de bridge inlogt, ingelogd blijft voor de normale sessie-levensduur.

Deep linking is een entry in de path configuration. Universal Links op iOS en App Links op Android routeren beide naar de shell, die vervolgens de juiste Rails view presenteert.

Performance en waar het écht stukgaat

Laat ik eerlijk zijn over de faalmodi die ik bij Hotwire Native in productie heb gezien.

Een koude start van de webview op een low-end Android-toestel kan 800ms tot 1,5 seconde duren voordat de eerste content geschilderd is. Dat is grotendeels WebView-warmup, niet je server. De oplossing is om tijdens app-launch een splash-screen-URL te preloaden en één WebView warm te houden. De gem heeft hier helpers voor — gebruik ze.

Turbo.cache-interacties met het sluiten van native modals kunnen verouderde content veroorzaken. Als een gebruiker een new-modal opent, hem invult, indient en de modal sluit, moet het onderliggende scherm verversen. De path configuration met pull_to_refresh_enabled: true dekt het meeste hiervan af. Voor de rest broadcast je een Turbo Stream en laat het scherm zichzelf updaten.

Formulieren met file uploads binnen een modal kunnen state kwijtraken als de gebruiker de app naar de achtergrond stuurt tijdens de picker. Wikkel het formulier in een data-turbo-permanent-element, of accepteer het en gebruik een bridge component voor de upload (wat ik sowieso aanraad).

Animaties die door CSS aangestuurd worden, werken; animaties die door JavaScript scroll-listeners worden aangestuurd, niet. De webview-scrolling op iOS hoort bij WKScrollView, en die vuurt geen scroll-events af zoals een browser dat doet. Hou het op CSS-animaties en intersection observers, en je hebt geen last.

Het profiel voor batterij en geheugen is dramatisch beter dan bij React Native. Een typische Hotwire Native-app idle’t rond de 35-50 MB geheugen. Een typische React Native-app van vergelijkbare complexiteit zit tussen 120 en 180 MB. Dat telt meer dan de marketing zegt, vooral op Android.

Kosten en wat ik klanten vertel

Voor een typische Rails-SaaS die zijn eerste mobiele app uitbrengt, gaat mijn budgetgesprek zo. De native shell voor iOS kost twee tot drie dagen werk. De native shell voor Android twee tot drie dagen. Drie tot vijf bridge components (camera, push, biometrie, share sheet, native datepicker) is nog een week. App Store- en Play Console-setup, certificaten, screenshots, copy: een week. Dat is grofweg vier tot vijf weken tijd van één senior developer om beide apps in beide stores te krijgen.

Vergelijk dat met een React Native-rebuild voor een bestaande Rails-app: minimaal drie tot zes maanden, plus permanent onderhoud van een aparte codebase, plus de kosten dat elke UI-wijziging in twee plekken moet landen. De rekensom is niet eens spannend voor de meeste teams.

De kostenvraag die ik het vaakst krijg, is “wat gebeurt er als 37signals dit niet meer onderhoudt?” De shells zijn klein en forkbaar; het bridge-protocol is een gedocumenteerd JSON-berichtformaat; de Rails gem is een paar honderd regels. Als het ecosysteem morgen verdwijnt, hou je een onderhoudbare native shell over die je volledig in eigen beheer hebt. De blast radius van deze keuze is veel kleiner dan inzetten op een React Native major-version-migratie.

FAQ

Is Hotwire Native hetzelfde als Turbo Native?

Hotwire Native is de herdoopte en gemoderniseerde opvolger van Turbo Native. Het bevat de Turbo Native-libraries voor iOS en Android, plus het nieuwe path configuration-systeem, het bridge component-framework en de hotwire-native-rails gem. Als je vandaag begint, gebruik Hotwire Native — Turbo Native is in maintenance mode.

Kan ik Hotwire Native gebruiken als mijn app niet met Hotwire op het web is gebouwd?

Ja, met kanttekeningen. De shells renderen elke URL die je Rails-app uitserveert, dus een normale ERB- of React-op-de-server-app werkt prima. Je krijgt minder profijt van Turbo Drive’s instant navigatie, en bridge components zijn makkelijker te integreren als je bestaande web-JavaScript al Stimulus gebruikt. De meeste teams pakken Hotwire eerst op het web aan en voegen daarna native toe — de volgorde maakt minder uit dan mensen denken.

Hoe werkt authenticatie in Hotwire Native-apps?

De webview deelt cookies met de native shell, dus je bestaande Rails session-cookie-flow werkt zonder aanpassingen. Gebruikers loggen één keer in, en de cookie blijft staan tussen app-launches. Voor biometric unlock wikkel je de cookie in een Keychain-entry (iOS) of EncryptedSharedPreferences-entry (Android) en herauthenticeer je de gebruiker bij app-launch met Face ID of vingerafdruk. De Rails-sessie hoeft daar niets van te weten.

Wordt mijn app afgewezen door de App Store omdat het een “web view wrapper” is?

Apple’s guideline 4.2 wijst sinds 2017 pure webview-wrappers af, maar Hotwire Native-apps komen consistent door de review omdat ze native navigatie, native modal-presentatie, native bridge components voor camera en andere systeem-features, en zinvol meer functionaliteit bevatten dan alleen het laden van een website. Van de tientallen apps die ik op deze manier heb helpen uitleveren, is er geen één afgewezen op 4.2. Voeg minstens één bridge component toe voordat je inlevert, en je komt erdoorheen.


Denk je na over een mobiele app voor je Rails-SaaS, maar zie je op tegen de offerte voor React Native? TTB Software helpt Rails-teams om Hotwire Native-apps in weken — niet kwartalen — in de App Store en Google Play te krijgen. We doen Rails al negentien jaar en weten welke gevechten het waard zijn om aan te gaan.

#hotwire-native #hotwire-native-rails #rails-mobile-apps #turbo-native #ios-android-rails #hotwire-bridge-components #ruby-on-rails

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software