50 · Hotwire Native

The same pages, in a phone.

This is your web app inside a native shell. There's one web view, a browser with no address bar, embedded in the app. Every page is served by the same Rails app, styled by the same CSS. Nothing was rewritten.

Tap edit on a message. A native screen slides in from the right, with a native back button, and the web view renders the edit page inside it.

Content is all web. Navigation is all native.

→ tap edit
50 · going back

Back pops the stack.

Tap the back chevron. The screen slides away and the previous one is already there: Hotwire Native took a screenshot before pushing, so going back needs no request. On iOS the swipe-from-the-left-edge gesture does the same.

Two more rules you get for free. A link to the previous screen's URL pops instead of pushing. And a link to the current URL, or one marked data-turbo-action="replace", replaces the top screen instead of stacking a new one.

Links to other domains don't enter the stack at all. Tap the hotwired.dev link on the About page: it opens an in-app browser sheet.

→ tap the back chevron
50 · path configuration

Decorate URLs, not links.

You don't annotate every link for the app. You ship a JSON file of rules keyed by URL pattern, called the path configuration. The app loads a bundled copy at launch and a fresh copy from your server after, so you can change how the app navigates without a release.

Add a rule: anything ending in /edit opens as a modal. Then tap edit again.

Other properties: presentation: replace | pop | clear_all | refresh | none, pull_to_refresh_enabled, and your own keys that a native screen can read.

→ switch the rule on, tap edit
50 · bridge components

Let the web page ask for a native button.

The edit form has a web Save button. On a phone it would feel better in the top bar, native. A bridge component is a Stimulus controller that can talk to Swift or Kotlin:

import { BridgeComponent, BridgeElement } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "form"
  static targets = [ "submit" ]

  submitTargetConnected(target) {
    const title = new BridgeElement(target).title
    this.send("connect", { submitTitle: title }, () => target.click())
  }
}

Switch it on and open edit. The web side sends connect with the title; the native side draws a Save button in the bar and hides the web one. Tap it: native replies, the callback clicks the real submit button, and the ordinary form submission happens. Watch the messages cross the bridge on the right.

→ switch it on, tap edit, tap the native Save
50 · the ladder, again

Go native only where it pays.

Hotwire Native is progressive enhancement one more time. Every screen starts as a web page. When one needs more, you climb:

  • A web page in the native stack. Free. Ships when you deploy.
  • A bridge component for one native control: a top-bar button, a bottom sheet, a share menu, a native alert.
  • A fully native screen in Swift and Kotlin, when fidelity really matters. Give it a URL so the path configuration can route to it.

Basecamp has hundreds of screens and a handful are native. That's the ratio to aim for. Every page with its own URL is already a screen. (Navigation inside a frame stays inside the web view; only full visits, or frame navigations promoted with data-turbo-action="advance", reach the native stack.)