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.
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.
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.
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.
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.)