Someone on the team says "let's just wrap the web app."

Two weeks later the back button does something nobody can explain.

The login screen works on Android and loops forever on iOS.

The word "wrapper" did that. It's the wrong mental model, and every bug in this list comes from it.


Hotwire Native is a native application whose navigation stack is driven by your server's responses. The native side owns the screens, the transitions, and the back stack; the web side owns what goes inside a screen and, through the HTTP responses it sends, tells the native side when to push a new one. A URL visit is not a page load in a browser control. It is a request for a screen, which the native shell decides how to present.

That single sentence resolves most of what confuses people. Let's trace it.

The actual flow of one tap

A user taps a link inside the WebView. Here is what happens, in order:

  user taps <a href="/trades/42">
            ↓
  [WebView]  navigation delegate fires
             the web view does NOT navigate
            ↓
  [Native]   Session intercepts the proposed visit
            ↓
  [Native]   Path configuration matches "/trades/42"
             → rule says: context=default, presentation=push
            ↓
  [Native]   Navigator pushes a NEW view controller
             onto the native stack
            ↓
  [Native]   That controller owns a web view; it requests
             /trades/42 with the Turbo-Visit headers
            ↓
  [Server]   responds with HTML
            ↓
  [WebView]  renders inside the new native screen

The critical step is the second one. The WebView does not navigate itself. Hotwire Native intercepts the proposed navigation and cancels it, then decides — in native code — what screen to create. The web view you were looking at stays exactly where it was; a new one appears on top, inside a new native screen.

Junior instinct: "the page changed." What happened: a native view controller was pushed, and it happens to contain a web view showing a different URL.

This is why the native back button works at all. It's popping a native stack, not calling history.back().

Path configuration is a routing table

Path configuration is usually introduced as "a JSON file for settings." It isn't. It's the routing table that maps URL patterns to native presentation decisions, and it's evaluated top to bottom with last match winning.

{
  "rules": [
    {
      "patterns": [".*"],
      "properties": { "context": "default", "pull_to_refresh_enabled": true }
    },
    {
      "patterns": ["/new$", "/edit$"],
      "properties": { "context": "modal", "presentation": "default" }
    },
    {
      "patterns": ["/trades/\\d+/chart$"],
      "properties": { "view_controller": "chart" }
    }
  ]
}

Three things people get wrong here:

The patterns are regular expressions against the path, not glob-style route matchers. /new matches /renew-subscription. Anchor them.

Order matters and the last match wins, which is the opposite of how Rails routes work. Put the catch-all first and the specific rules after. Rails trained you into the opposite reflex and it will bite.

It is served from your server — usually at a URL the app fetches at launch, with a bundled copy as fallback. That means you can change how the app navigates without shipping a new build. It also means a bad path configuration deploy changes navigation for every installed app immediately. Treat it like a migration, not like a config tweak.

The bridge, and what it's actually for

The bridge is the channel for the things HTML genuinely can't do: a native share sheet, a system date picker, haptics, a camera.

A bridge component is a pair. On the web side, a Stimulus controller declares itself:

// app/javascript/controllers/bridge/share_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "share"

  share(event) {
    event.preventDefault()
    this.send("share", { url: this.element.href }, () => {
      // called back when the native side finishes
    })
  }
}

On the native side, a component with the same name receives the message and does something a browser cannot.

The rule that keeps this sane: the bridge carries intent, not markup. Send { url: "..." } and let native decide how a share sheet looks. The moment you start sending HTML across the bridge, or native starts reaching into the DOM, you have two codebases rendering the same screen and no way to reason about either.

And the fallback matters. That Stimulus controller must degrade to a plain link in a desktop browser. If the web app is broken without the native shell, you no longer have a web app — you have a native app with an unusually slow rendering engine.

Who owns the back stack

This is where the two-week bugs live. Native owns it. Every rule follows from that:

WRONG                          RIGHT
─────                          ─────
history.back() in JS           let native pop, or return
to leave a screen              a redirect the shell honors

Rendering a "cancel" link      Present it as a modal in path
inside a modal that            configuration; native dismisses
navigates back                 it as a unit

Redirect to the same URL       Redirect to a different URL, or
after a form POST              use turbo_stream, so the shell
                               knows a screen was replaced
                               rather than pushed

The login loop from the opening is almost always this: the app pushes /login as a normal screen, login succeeds, the server redirects back to /login's referrer, and the native stack now has two screens where the user expects zero. On iOS the modal never dismisses because nothing told it to. The fix is one path configuration rule marking the auth routes as modal, plus a redirect that leaves the modal context.

The most common mistakes

  1. Treating it as a wrapper. Every other mistake on this list is a special case of this one.
  2. Unanchored path configuration patterns. /new matching /renew is a real bug that ships and takes a day to find.
  3. Assuming first-match-wins ordering. Last match wins. Catch-all goes first.
  4. Driving navigation from JavaScript. history.back(), location.replace(), and friends fight the native stack and win only sometimes.
  5. Bridge components with no web fallback. The site breaks in a desktop browser and nobody notices for a month.
  6. Shipping a path configuration change like a config tweak. It reaches every installed app on next launch, including versions you stopped testing.

Frequently asked

Is Hotwire Native the same as a WebView wrapper?

No, and the difference is architectural rather than cosmetic. A wrapper loads a site inside one web view and lets the site handle its own navigation. Hotwire Native intercepts every proposed navigation, consults a path configuration, and creates a native screen for each one — so transitions, the back stack, modals, and native components are all owned by the platform. The web app supplies the contents of a screen; it does not supply the screen.

When should a screen be native instead of a WebView?

Three cases pay for themselves: anything using hardware (camera, biometrics, location), anything with a long scrolling list where recycling matters, and anything that must work offline. Everything else is usually cheaper as HTML, because the whole reason you chose Hotwire Native is that one team ships one implementation. Going native for a form because it "feels slow" is almost always fixing a latency problem in the wrong layer.

Will Apple reject a Hotwire Native app for being a wrapper?

The guideline that gets cited is about apps that provide no functionality beyond a repackaged website. Apps that use native navigation, native components for platform features, and push notifications are routinely approved — Basecamp and HEY ship this way. The risk is real for an app that is genuinely one web view and nothing else, which is exactly what this architecture is not.

Can I use Hotwire Native without Rails?

Yes. The native side only needs HTML responses and, optionally, a path configuration JSON endpoint. Rails gets the turbo-rails conveniences, but nothing in the navigation model requires it.

A quick mental map

tap a link
  ↓
native intercepts (web view does NOT navigate)
  ↓
path configuration decides: push? modal? native controller?
  ↓
native creates a screen
  ↓
that screen loads the URL and renders HTML
  ↓
back button pops the NATIVE stack
  ↓
need something HTML can't do? → bridge, carrying intent only

Conclusion

Hotwire Native is a good trade when one team has to ship web and mobile and the app is mostly forms, lists, and reading. It is a bad trade when the app is a canvas, a game, or an offline-first tool.

But you can't evaluate the trade while you still think of it as a wrapper. Native owns the stack. The server proposes screens. The bridge carries intent.

Get those three straight and the back button stops being haunted.