From d377a26d7085dd093e55f6ee90df304b349902c6 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov Date: Thu, 8 Oct 2026 18:21:58 +0300 Subject: [PATCH 01/17] docs: add quick start, how it works, feel native --- docs/guide/feel-native.md | 219 ++++++++++++++++++++++++++++++++ docs/guide/how-it-works.md | 143 +++++++++++++++++++++ docs/guide/quick-start.md | 252 +++++++++++++++++++++++++++++++++++++ 3 files changed, 614 insertions(+) create mode 100644 docs/guide/feel-native.md create mode 100644 docs/guide/how-it-works.md create mode 100644 docs/guide/quick-start.md diff --git a/docs/guide/feel-native.md b/docs/guide/feel-native.md new file mode 100644 index 0000000..90635e9 --- /dev/null +++ b/docs/guide/feel-native.md @@ -0,0 +1,219 @@ +# Feel native + +After the [quick start](/guide/quick-start), your app runs in a native shell +but still looks like a website in places. This page covers the changes that +make the biggest difference: hiding web-only parts, closing a modal after a +form, and adding a native button. + +## Tell the app apart from a browser + +Hotwire Native adds its name to the web view's user agent on both platforms. +iOS requests contain `Hotwire Native iOS;` and Android requests contain +`Hotwire Native Android;`. Check for `Hotwire Native` and you cover both. + +Your server sees the user agent on every request, including Inertia's own +requests. The simplest setup checks it there and shares the result with every +page as an Inertia prop: + +::: code-group + +```php [Laravel] +// app/Http/Middleware/HandleInertiaRequests.php +public function share(Request $request): array +{ + return [ + ...parent::share($request), + 'nativeApp' => str_contains($request->userAgent() ?? '', 'Hotwire Native'), + ]; +} +``` + +```ruby [Rails] +# app/controllers/application_controller.rb +class ApplicationController < ActionController::Base + inertia_share do + { nativeApp: native_app? } + end + + private + + def native_app? + request.user_agent.to_s.include?("Hotwire Native") + end +end +``` + +::: + +In JavaScript that runs outside a component, read the user agent directly: + +```js +const isNativeApp = + typeof navigator !== 'undefined' && navigator.userAgent.includes('Hotwire Native') +``` + +The `typeof` check keeps the line safe during server-side rendering, where +`navigator` may not exist. + +::: warning Don't check `window.webkit` +`window.webkit.messageHandlers` comes from WKWebView, the web view on iOS. +Android's web view doesn't have it, so a check like +`window.webkit?.messageHandlers?.turbo` is false on Android. Use the user +agent instead. +::: + +## Hide web-only parts + +In the app, the shell already shows a navigation bar with the page title and a +back button. Your site's own header, back links, and footer now repeat it. +Hide them when `nativeApp` is true: + +::: code-group + +```jsx [React] +import { usePage } from '@inertiajs/react' + +export default function Layout({ children }) { + const { nativeApp } = usePage().props + + return ( + <> + {!nativeApp && } +
{children}
+ {!nativeApp && } + + ) +} +``` + +```vue [Vue] + + + +``` + +```svelte [Svelte] + + +{#if !page.props.nativeApp} + +{/if} +
{@render children()}
+{#if !page.props.nativeApp} + +{/if} +``` + +::: + +The server sends the prop with the page, so the header doesn't flash on screen +while the page loads. + +### Page titles + +The native navigation bar shows the page's ``, the same one you set with +Inertia's `<Head>`. If your `createInertiaApp` adds your app's name to every +title, the bar shows `Posts - Acme Shop` on every screen. Drop the suffix in +the app: + +```js +createInertiaApp({ + title: (title) => (isNativeApp ? title : `${title} - Acme Shop`), + // … +}) +``` + +## Close a modal after a form + +In the shells from `init`, a page whose URL ends in `/new` or `/edit` opens as +a modal. After the form saves, you want the modal to close and show the screen +underneath. A redirect can't do this: Inertia follows a redirect inside its own +request, so the shell never sees it. + +Instead, answer the form with a historical location. Hotwire Native reserves +three paths for this, and the shell handles them itself. Your server doesn't +need routes for them. `/recede_historical_location` closes the modal, or goes +back one screen when there's no modal. + +Send it as an Inertia location visit, and only to the app. Browsers get your +usual redirect: + +::: code-group + +```php [Laravel] +// app/Http/Controllers/PostController.php +use App\Models\Post; +use Illuminate\Http\Request; +use Inertia\Inertia; + +public function store(Request $request) +{ + Post::create($request->validate([ + 'title' => ['required', 'string', 'max:255'], + ])); + + if (str_contains($request->userAgent() ?? '', 'Hotwire Native')) { + return Inertia::location('/recede_historical_location'); + } + + return redirect()->route('posts.index'); +} +``` + +```ruby [Rails] +# app/controllers/posts_controller.rb +def create + @post = Post.new(post_params) + + if @post.save + return inertia_location("/recede_historical_location") if native_app? + + redirect_to posts_path + else + redirect_to new_post_path, inertia: { errors: @post.errors } + end +end +``` + +::: + +`inertia-native` hands the location visit to the shell, and the shell closes +the modal. Validation errors still go back to the form, so they show up inside +the modal as usual. + +To close the modal and also reload the screen under it, so a list shows the +new post, answer with `/refresh_historical_location` instead. +[Historical locations](/guide/navigation#historical-locations) lists all three +paths and has a ready-made Rails helper. + +<!-- TODO: confirm on Android that recede from a modal pops only the modal when the screen under it isn't the first one. NavigatorRule routes the modal result's POP again in the main stack (hotwire-native-android 1.3.1, NavigatorRule.kt newPresentation). --> + +## Add a native button + +A native button sits in the navigation bar, next to the title, where native +apps put actions like **Save** or **Edit**. The [Button](/components/button) +component adds one from your page and calls your code when someone taps it. In +a browser it renders your normal button instead. + +The shells from `init` already include the native half of this component, and +of the [Alert](/components/alert) component. You only copy the web half into +your app. + +## Next steps + +- [Navigation](/guide/navigation): how every kind of Inertia visit maps to + native screens. +- [Bridge components](/components/overview): how to build your own native + components. diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md new file mode 100644 index 0000000..35786d4 --- /dev/null +++ b/docs/guide/how-it-works.md @@ -0,0 +1,143 @@ +# How it works + +This page explains Hotwire Native for Inertia developers who haven't used it. +It covers what the native app does, what your web app does, and the two +Hotwire ideas you'll meet in the rest of these docs: path configuration and +bridge components. + +## The mental model + +Think of the native app as a picture frame and your web app as the picture. +The frame is a real iOS or Android app. It owns the parts around the page: +the navigation bar, the back button, modals, and error screens. Your server +still renders every page, the same way it does for a browser. + +Each time you visit a new page, the frame opens a new native screen and shows +the page inside it. Going back closes that screen. That's why the app feels +native even though every page is your own Inertia page. + +[Hotwire Native](https://native.hotwired.dev) is the open-source framework that +provides the frame. 37signals makes it. It replaced Turbo Native and Strada, +the two libraries 37signals built its own mobile apps on. + +## What happens when you tap a link + +Hotwire Native was built for Turbo, the page-navigation library that ships +with Rails. Each time the app loads one of your pages, it adds a small script +that expects to find Turbo there. `inertia-native` stands in for Turbo and +translates between that script and Inertia's router. + +Here is a link tap, step by step: + +```text + You tap <Link href="/posts/1"> + │ + ▼ + inertia-native stops Inertia from changing the current page + and asks the native app to visit /posts/1 + │ + ▼ + The app checks its path configuration: + open /posts/1 as a new screen, or as a modal? + │ + ▼ + The app opens a new native screen + and loads /posts/1 into it +``` + +In a regular browser no native app is listening, so `inertia-native` does +nothing and Inertia navigates as usual. The same JavaScript bundle serves both. + +[Navigation](/guide/navigation) lists every kind of visit and how it maps to +native screens. + +## What the native app does and what your web app does + +These docs call the native app the shell, because it holds your web app but +has little content of its own. Here is how the work splits between the two: + +| The native shell | Your web app | +| --- | --- | +| Starts at your URL | Renders every page, as it does for a browser | +| Shows each page in a web view, the browser engine an app can embed | Decides what's on each page | +| Opens screens and modals, and draws the back button | Handles links, forms, validation, and sign-in | +| Shows the page's `<title>` in the navigation bar | Sets that title, for example with Inertia's `<Head>` | +| Shows native error screens and pull to refresh | Returns errors as usual | + +Most changes you make happen on the web side and reach users when you deploy. +You only touch the shell for things like the app name, its icon, or a new +native component. + +## Path configuration + +Path configuration is a JSON file of rules that tells the shell how to open +each URL. Each rule has a list of URL patterns and the properties to apply +when a URL matches. These are the shared rules in the shells that `init` +creates: + +```json +{ + "settings": {}, + "rules": [ + { + "patterns": ["/new$", "/edit$"], + "properties": { + "context": "modal", + "pull_to_refresh_enabled": false + } + }, + { + "patterns": ["^/$"], + "properties": { + "presentation": "clear_all" + } + } + ] +} +``` + +Read it like this. A URL ending in `/new` or `/edit` opens as a modal, with +pull to refresh turned off. The home page `/` clears the navigation history, +so going home doesn't stack one more screen on top. The patterns are regular +expressions matched against the URL's path. When several rules match, the app +combines their properties, and a later rule wins over an earlier one. + +The Android file starts with one more rule. It tells Android which kind of +native screen shows web pages. You don't need to change it. + +### Where it lives + +The shell reads path configuration from two places: + +- **A file bundled in the app.** It's `ios/App/path-configuration.json` and + `android/app/src/main/assets/json/path-configuration.json`. The app uses + this file from the first launch. +- **A copy on your server, if you serve one.** When the app starts, it + downloads `/inertia-native/path-configuration/ios_v1.json` or + `/inertia-native/path-configuration/android_v1.json` from your app's URL. + If the download works, its rules replace the bundled ones, and the app keeps + a copy for the next launch. If your server doesn't have that route, the app + keeps using the bundled file. + +Serving the file lets you change navigation by deploying your web app, without +shipping an app update. [iOS](/native/ios#path-configuration) and +[Android](/native/android#path-configuration) show a Laravel and a Rails route +that serve it. + +## Bridge components + +A bridge component lets a web page ask the shell for a piece of native UI, such +as a button in the navigation bar, and hear back when someone uses it. Each +component has a web half in your JavaScript and a native half in the shell, +and when the native half is missing the web half renders your normal web +markup instead. + +[Bridge components](/components/overview) covers how to use them, and the +shells from `init` already include the native halves of the ready-made ones. + +## Next steps + +- [Feel native](/guide/feel-native): hide web-only parts and close modals + after forms. +- [Navigation](/guide/navigation): every kind of Inertia visit and how it maps + to native screens. diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md new file mode 100644 index 0000000..01b1503 --- /dev/null +++ b/docs/guide/quick-start.md @@ -0,0 +1,252 @@ +# Quick start + +This guide takes an Inertia app you already have and runs it as a native iOS +and Android app in a simulator. It works with any backend. The examples show +Laravel and Rails, and the tabs show where the two differ. + +You don't need to know Hotwire Native, Swift, or Kotlin to follow along. +[How it works](/guide/how-it-works) explains the moving parts once you've seen +them run. + +## Before you start + +You need an Inertia app and the tools for the platforms you want to build: + +- An Inertia.js app on Inertia 3 or later, with React, Vue, or Svelte. +- For iOS: a Mac with Xcode 16 or later. +- For Android: Android Studio with at least one emulator. Android Studio + calls an emulator an Android Virtual Device (AVD), and you create one in its + **Device Manager**. + +You can skip the tools for a platform you don't build. + +<!-- TODO: confirm the minimum Android Studio version for AGP 9.2.1 --> + +## 1. Run `init` + +Run this in your app's root, the directory with `package.json`: + +```bash +npx inertia-native init +``` + +<!-- TODO: confirm the first inertia-native version that ships `init` and `run` --> + +It asks a few questions. Press Enter to accept each default: + +- **Platforms**: iOS, Android, or both. +- **App name**: the name under the app icon. The default comes from your + directory name. +- **Bundle ID**: the app's unique ID, written like a reversed domain name + (`com.acme.shop`). The App Store and Google Play use it to tell apps apart. + The default `com.example.…` is fine for trying things out. +- **Dev server URL**: the address the app loads. Use the same URL you open in + your browser during development. + +Then it changes four things in your project. The output looks like this: + +::: code-group + +```text [Laravel] +$ npx inertia-native init +? Platforms › both +✓ Found entrypoint resources/js/app.tsx +? App name › Acme Shop +? Bundle ID › com.example.acmeshop +? Dev server URL › http://localhost:8000 +✓ Added inertia-native to package.json (npm) +✓ Patched resources/js/app.tsx +✓ Created ios/ and android/ +✓ Added "ios" and "android" scripts to package.json + +Next: start your dev server (composer run dev), then run: npm run ios +``` + +```text [Rails] +$ npx inertia-native init +? Platforms › both +✓ Found entrypoint app/frontend/entrypoints/inertia.tsx +? App name › Acme Shop +? Bundle ID › com.example.acmeshop +? Dev server URL › http://localhost:3000 +✓ Added inertia-native to package.json (npm) +✓ Patched app/frontend/entrypoints/inertia.tsx +✓ Created ios/ and android/ +✓ Added "ios" and "android" scripts to package.json + +Next: start your dev server (bin/dev), then run: npm run ios +``` + +::: + +<!-- TODO: re-check the sample output against the released CLI --> + +`init` finds your Inertia entrypoint by looking for `createInertiaApp(` in the +usual places. It picks `http://localhost:8000` when it finds `artisan` and +`http://localhost:3000` otherwise. If your app runs somewhere else, type that +URL at the prompt. + +`init` is safe to run again: it skips anything that's already done. See +[What `init` did](#what-init-did) for the details, or +[`init` options](#init-options) to run it without questions. + +## 2. Start your dev server + +Start your app the way you always do: + +::: code-group + +```bash [Laravel] +composer run dev +``` + +```bash [Rails] +bin/dev +``` + +::: + +Open the dev server URL in your browser to check that the app is up. The +native app loads the same URL, so it needs the server running. + +## 3. Run the app + +In a second terminal, run the platform you want: + +```bash +npm run ios +npm run android +``` + +`npm run ios` builds the app, starts an iPhone simulator, installs the app, +and opens it. `npm run android` builds the app, starts your first emulator if +none is running, installs the app, and opens it. The first build downloads +Hotwire Native and the other dependencies, so it takes longer than the ones +after it. + +You should see your home page on a native screen. Try these: + +- Tap a link. A new screen slides in, with a native back button. +- Go back with the back button or a swipe from the left edge. +- Open a page whose URL ends in `/new` or `/edit`. It opens as a modal: a + screen that slides up from the bottom. +- Pull the page down to reload it. + +::: warning Blank screen on the Android emulator? +Inside the emulator, `localhost` means the emulator itself. The app already +handles that for your URL, but if your page loads scripts from the Vite dev +server at a `localhost` address, the emulator can't reach them. See +[Blank screen on the emulator](/native/android#blank-screen-on-the-emulator). +::: + +To run on your own phone, see [iOS](/native/ios#run-on-your-iphone) and +[Android](/native/android#run-on-your-phone). + +## 4. Make it feel native + +Your app runs, but it still shows web parts that a native app doesn't need, +such as your site's top navigation bar. [Feel native](/guide/feel-native) +shows how to hide them, close a modal after a form, and add a native button. + +## What `init` did + +`init` made four changes. You can make them by hand instead if you prefer to +see each step. + +### The package and two lines of JavaScript + +`init` added the `inertia-native` package with your package manager, then +added two lines to your Inertia entrypoint: + +::: code-group + +```js [Laravel] +// resources/js/app.tsx (or app.ts, app.js) +import { createInertiaApp } from '@inertiajs/react' +import { initInertiaNative } from 'inertia-native' // [!code ++] + +initInertiaNative() // [!code ++] + +createInertiaApp({ + // … +}) +``` + +```js [Rails] +// app/frontend/entrypoints/inertia.tsx (or inertia.ts, inertia.js) +import { createInertiaApp } from '@inertiajs/react' +import { initInertiaNative } from 'inertia-native' // [!code ++] + +initInertiaNative() // [!code ++] + +createInertiaApp({ + // … +}) +``` + +::: + +The call must come before `createInertiaApp`. In a regular browser it does +nothing, so your website keeps working as before. The +[installation page](/guide/installation) covers the options it takes. + +If `init` can't find your entrypoint, or finds a file it can't safely change, +it prints these two lines for you to add yourself. + +### The native projects + +`init` created two native projects next to your app code: + +- `ios/`: an Xcode project. Without the script, open `ios/App.xcodeproj` in + Xcode, pick a simulator at the top of the window, and press **Run**. +- `android/`: a Gradle project. Without the script, open the `android/` + directory in Android Studio, pick an emulator, and press **Run**. + +Both are small and readable. [iOS](/native/ios) and [Android](/native/android) +explain each file and how to change the name, bundle ID, and URL later. + +### The scripts + +`init` added two scripts to `package.json`, unless you already had scripts +with those names: + +```json +{ + "scripts": { + "ios": "inertia-native run ios", + "android": "inertia-native run android" + } +} +``` + +`run` warns you if your dev server doesn't answer, and tells you how to fix a +missing Xcode, Android SDK, or JDK. The native pages list its options. + +## `init` options + +Pass flags to skip the questions, for example in a script: + +```bash +npx inertia-native init ios --name "Acme Shop" --bundle-id com.acme.shop --url http://localhost:8000 +``` + +| Option | What it does | +| --- | --- | +| `ios`, `android`, `both` | Which native projects to create. Default: ask, or both. | +| `--name` | Name under the app icon. Default: your directory name, title-cased (`acme-shop` becomes `Acme Shop`). | +| `--bundle-id` | iOS bundle ID and Android application ID. Default: `com.example.` plus the name in lowercase letters and digits. | +| `--url` | URL the app loads. Default: `http://localhost:8000` if `artisan` exists, otherwise `http://localhost:3000`. | +| `--entrypoint` | Path to your Inertia entrypoint, if `init` picks the wrong file. | +| `--skip-install` | Don't add the npm package. | +| `--force` | Replace existing `ios/` and `android/` directories. This deletes changes you made in them. | +| `--yes` | Accept every default without asking. | + +`init` only asks questions in an interactive terminal. Elsewhere it uses the +flags and defaults. + +## Next steps + +- [Feel native](/guide/feel-native): hide web-only parts, close modals after + forms, and add native buttons. +- [How it works](/guide/how-it-works): what the native app does and what your + web app does. From ab27b5b31332b0d6195b6d6f0b717450df8ec175 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Thu, 8 Oct 2026 18:22:01 +0300 Subject: [PATCH 02/17] docs: document the iOS shell that init creates --- docs/native/ios.md | 260 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 233 insertions(+), 27 deletions(-) diff --git a/docs/native/ios.md b/docs/native/ios.md index 00e74ad..7b9a582 100644 --- a/docs/native/ios.md +++ b/docs/native/ios.md @@ -1,44 +1,250 @@ # iOS -The native iOS shell is a standard [Hotwire Native iOS](https://native.hotwired.dev/ios/overview) -app that loads your Inertia application in a web view. The package handles the -web side; the iOS app provides the navigation stack, path configuration, and any -bridge components. - -::: tip Reference app -A runnable example lives in -[demo-ios](https://github.com/inertia-native/demo-ios). -Clone it to see a working setup end to end. +`npx inertia-native init` creates an `ios/` directory with a small Hotwire +Native iOS app. This page explains its files, how to run it on a simulator and +on your iPhone, and how to change it later. If you haven't run `init` yet, +start with the [quick start](/guide/quick-start). + +## Requirements + +You need a Mac with Xcode to build the app: + +- Xcode 16 or later. +- The app runs on iOS 17 and later. +- Hotwire Native iOS 1.3.1. Xcode downloads it with Swift Package Manager on + the first build. + +## What `init` creates + +The project is plain Swift with no generated code, so you can read and change +every file: + +```text +ios/ +├── App.xcodeproj Xcode project: target "App", scheme "App" +└── App/ + ├── AppConfig.swift the URL the app loads + ├── AppDelegate.swift Hotwire Native setup: path configuration, + │ bridge components, options + ├── SceneDelegate.swift opens your URL on launch, handles failed pages + ├── path-configuration.json bundled navigation rules + ├── Bridge/ native halves of the alert, button, form, + │ menu, and overflow-menu components + ├── Assets.xcassets app icon and accent color + └── Info.plist allows plain HTTP to local network addresses +``` + +`AppDelegate.swift` also sets two options: the back button shows only an +arrow, and every modal gets a **Done** button. + +## Run on the simulator + +Run this from your app's root, with your dev server running: + +```bash +npm run ios +``` + +It builds the app into `ios/build`, picks the simulator that's already +running or the newest iPhone, opens the Simulator app, then installs and +launches your app. It warns you if your dev server doesn't answer. + +To pick a simulator, pass its name after `--`: + +```bash +npm run ios -- --device "iPhone 16 Pro" +``` + +`xcrun simctl list devices available` lists the names you can use. + +You can also run from Xcode. Open `ios/App.xcodeproj`, choose a simulator in +the run destination menu at the top of the window, and press **Run** (⌘R). + +The simulator shares your Mac's network, so `http://localhost:8000` or +`http://localhost:3000` reaches your dev server without changes. + +## Run on your iPhone + +`npm run ios` only targets simulators, so use Xcode for a real device. Follow +these steps: + +1. Connect your iPhone to your Mac with a cable. +2. Open `ios/App.xcodeproj` in Xcode. +3. Select the **App** target, open **Signing & Capabilities**, and choose your + **Team**. The project ships without a team, so Xcode can't sign the app + until you pick one. If Xcode says the bundle ID isn't available, change it + to one that's yours, like `com.yourname.acmeshop`. +4. On the iPhone, turn on **Developer Mode** in **Settings** > **Privacy & + Security**. +5. Point the app at your Mac and start your server for the network, as + described below. +6. Choose your iPhone in the run destination menu and press **Run**. + +On the phone, `localhost` means the phone itself. Use your Mac's address on +your Wi-Fi network instead, for example `http://192.168.1.20:8000`. Find it in +**System Settings** > **Wi-Fi** > **Details**, or run +`ipconfig getifaddr en0`. Put it in `ios/App/AppConfig.swift`: + +```swift +static let baseURL = URL(string: "http://192.168.1.20:8000")! +``` + +The app's `Info.plist` sets `NSAllowsLocalNetworking`, which lets iOS load +plain HTTP from local network addresses like this one. + +Your server must accept connections from the network, and the page's scripts +must come from an address the phone can reach. A setup that loads scripts from +the Vite dev server at `localhost` leaves the page blank on the phone, and +Laravel works that way. The reliable option is to run your app without the +Vite dev server: + +::: code-group + +```bash [Laravel] +npm run build +php artisan serve --host=0.0.0.0 +``` + +```bash [Rails] +# Rails builds the assets when the Vite dev server isn't running. +bin/rails server -b 0.0.0.0 +``` + +::: + +<!-- TODO: document a Vite dev server setup that keeps hot reload on a real device (server.host + server.hmr.host, Vite CORS) after testing it --> +<!-- TODO: confirm whether iOS asks for Local Network permission when the app loads a LAN dev server --> + +## Change the name, bundle ID, or URL + +`init` wrote your answers into a few places. Change them there: + +| What | Where | +| --- | --- | +| Name under the icon | Xcode: **App** target > **General** > **Display Name** | +| Bundle ID | Xcode: **App** target > **General** > **Bundle Identifier** | +| URL the app loads | `baseURL` in `ios/App/AppConfig.swift` | + +Xcode saves the name and bundle ID in `ios/App.xcodeproj/project.pbxproj`. The +project, target, and scheme stay named `App`. + +Before you ship, point release builds at your production site. Debug builds +can keep your dev server: + +```swift +// ios/App/AppConfig.swift +#if DEBUG +static let baseURL = URL(string: "http://localhost:8000")! +#else +static let baseURL = URL(string: "https://acme.example")! +#endif +``` + +You can also run `npx inertia-native init ios --force` with new values. It +recreates `ios/` from scratch, so it deletes any changes you made there. + +## Path configuration + +Path configuration is the set of rules that decides how each URL opens, for +example as a modal. [How it works](/guide/how-it-works#path-configuration) +explains the format. + +The app reads it from two places: + +1. `ios/App/path-configuration.json`, bundled in the app. +2. `/inertia-native/path-configuration/ios_v1.json` on your server, if you + serve it. + +When the app starts, it applies the bundled file, then the copy it saved from +your server last time, then downloads a fresh copy. A downloaded copy replaces +the bundled rules completely. If the download fails, for example with a 404, +the app keeps what it has. + +### Serve it from your app + +Serving the file lets you change navigation with a deploy instead of an app +update. Add a route that returns the same JSON as the bundled file: + +::: code-group + +```php [Laravel] +// routes/web.php +Route::get('/inertia-native/path-configuration/ios_v1.json', fn () => [ + 'rules' => [ + [ + 'patterns' => ['/new$', '/edit$'], + 'properties' => ['context' => 'modal', 'pull_to_refresh_enabled' => false], + ], + [ + 'patterns' => ['^/$'], + 'properties' => ['presentation' => 'clear_all'], + ], + ], +]); +``` + +```ruby [Rails] +# config/routes.rb +get "inertia-native/path-configuration/ios_v1", to: "path_configurations#ios" + +# app/controllers/path_configurations_controller.rb +# ActionController::Base skips your app-wide filters, such as sign-in checks. +class PathConfigurationsController < ActionController::Base + def ios + render json: { + settings: {}, + rules: [ + { patterns: ["/new$", "/edit$"], properties: { context: "modal", pull_to_refresh_enabled: false } }, + { patterns: ["^/$"], properties: { presentation: "clear_all" } } + ] + } + end +end +``` + ::: -## Outline +Keep these points in mind: -1. Create a Hotwire Native iOS app and add the SDK via Swift Package Manager. -2. Point its `Navigator` at your app's URL. -3. Add a `path-configuration` JSON to control which routes present as modals, - pushes, etc. -4. Register the bridge components your web pages use (`button`, `form`, `menu`, - …) if any. +- **Include every rule.** The server copy replaces the bundled file, so start + from a copy of it. +- **Keep the route public.** The app downloads the file on launch, before + anyone signs in. +- **Leave out `settings` in PHP.** PHP turns an empty array into `[]` instead + of `{}`. The key is optional, so skip it unless you have settings to send. +- **Relaunch to see changes.** The app downloads the file when it starts. -## Registering bridge components +## Bridge components -Each bridge component your web pages use needs a Swift counterpart registered at -launch, otherwise the web side falls back to its ordinary markup: +The native halves of bridge components live in `ios/App/Bridge/`, and +`AppDelegate.swift` registers them at launch: ```swift Hotwire.registerBridgeComponents([ + AlertComponent.self, ButtonComponent.self, FormComponent.self, MenuComponent.self, + OverflowMenuComponent.self, ]) ``` -Ready-made components — Swift source plus the matching web half — live in the -[bridge-components](https://github.com/inertia-native/bridge-components) -registry. See [Button](/components/button) for a worked example, including the -minimum SDK version they require. +To add a component, put its Swift file in `ios/App/Bridge/` and add it to this +list. Xcode picks up new files in the `App` folder without extra steps. See +[Bridge components](/components/overview) for the web side. -::: info Work in progress -A full step-by-step walkthrough is being written. For now, the demo app is the -source of truth. -::: +## Debugging + +Debug builds make the web view inspectable. Open Safari on your Mac, turn on +**Show features for web developers** in **Settings** > **Advanced**, then pick +the simulator or iPhone from the **Develop** menu. Hotwire Native's own logs +show up in Xcode's console in debug builds. + +To log the messages between your page and the app, turn on `debug` in your +entrypoint. See [Debugging](/guide/navigation#debugging). + +## Handle failed pages + +When a page fails to load, `SceneDelegate.swift` shows Hotwire Native's error +screen with a **Retry** button. To handle a case yourself, such as sending a +401 to your sign-in page, edit `visitableDidFailRequest` there. From c66e5509b8b3f65fe725f1d86b0f9320682e17e0 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Thu, 8 Oct 2026 18:22:05 +0300 Subject: [PATCH 03/17] docs: document the Android shell that init creates --- docs/native/android.md | 372 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 354 insertions(+), 18 deletions(-) diff --git a/docs/native/android.md b/docs/native/android.md index cf91fa0..4c3ba5b 100644 --- a/docs/native/android.md +++ b/docs/native/android.md @@ -1,25 +1,361 @@ # Android -The native Android shell is a standard [Hotwire Native Android](https://native.hotwired.dev/android/overview) -app that loads your Inertia application in a web view. The package handles the -web side; the Android app provides the navigation stack, path configuration, and -any bridge components. - -::: tip Reference app -A runnable example lives in -[demo-android](https://github.com/inertia-native/demo-android). -Clone it to see a working setup end to end. +`npx inertia-native init` creates an `android/` directory with a small Hotwire +Native Android app. This page explains its files, how to run it on an emulator +and on your phone, and how to change it later. If you haven't run `init` yet, +start with the [quick start](/guide/quick-start). + +## Requirements + +You need Android Studio to build and run the app: + +- Android Studio with at least one emulator. Create one in its **Device + Manager**. Android Studio also installs the Android SDK and comes with a JDK. +- The app runs on Android 9 (API level 28) and later. +- Hotwire Native Android 1.3.1. Gradle downloads it on the first build. + +<!-- TODO: confirm the minimum Android Studio version for AGP 9.2.1 / Gradle 9.4.1 --> + +## What `init` creates + +The project is plain Kotlin with no generated code, so you can read and change +every file: + +```text +android/ +├── app/ +│ ├── build.gradle.kts app ID, the URL the app loads, SDK versions +│ └── src/ +│ ├── main/ +│ │ ├── AndroidManifest.xml +│ │ ├── assets/json/ +│ │ │ └── path-configuration.json bundled navigation rules +│ │ ├── kotlin/dev/inertianative/app/ +│ │ │ ├── MainApplication.kt Hotwire Native setup: path +│ │ │ │ configuration, bridge components +│ │ │ ├── MainActivity.kt opens your URL on launch +│ │ │ ├── WebFragment.kt the screen that shows a web page +│ │ │ ├── Urls.kt the URL, adjusted for the emulator +│ │ │ └── bridge/ native halves of the alert, button, +│ │ │ form, menu, and overflow-menu components +│ │ └── res/values/strings.xml app name +│ └── debug/res/xml/ +│ └── network_security_config.xml plain HTTP to your computer, +│ debug builds only +├── gradle/libs.versions.toml dependency versions, including Hotwire Native +└── gradlew Gradle wrapper +``` + +The Kotlin package stays `dev.inertianative.app` in every app. Your app's +identity is its application ID, which `init` sets from your bundle ID. You +don't need to rename the package. + +`WebFragment.kt` adds a spinner to the toolbar while a form submits. + +## Run on the emulator + +Run this from your app's root, with your dev server running: + +```bash +npm run android +``` + +It builds and installs a debug build with Gradle, starts your first emulator +if none is running, waits for it to boot, and launches your app. It warns you +if your dev server doesn't answer. + +To pick an emulator, pass its name after `--`: + +```bash +npm run android -- --avd Pixel_9 +``` + +`emulator -list-avds` prints the names you can use. The `emulator` tool is in +the Android SDK's `emulator` directory. + +You can also run from Android Studio. Open the `android/` directory, wait for +Gradle to finish syncing, choose an emulator in the toolbar, and press +**Run**. + +### How the emulator reaches your computer + +Inside the emulator, `localhost` is the emulator itself. Your computer is +`10.0.2.2`. In debug builds, `Urls.kt` rewrites a `localhost` or `127.0.0.1` +URL to `10.0.2.2`, so `http://localhost:8000` works without changes. + +Android blocks plain HTTP by default. In debug builds, +`network_security_config.xml` allows it for `10.0.2.2`, `localhost`, and +`127.0.0.1` only. Release builds keep Android's default and need HTTPS. + +### Blank screen on the emulator + +If the app opens but the page stays blank or unstyled, the page's scripts and +styles probably didn't load. The app rewrites your URL, but it can't rewrite +the addresses inside your page. A layout that loads scripts from the Vite dev +server at a `localhost` address points the emulator at itself. Laravel works +this way: its pages load scripts from the address in `public/hot`, such as +`http://[::1]:5173`. + +To check, open `chrome://inspect` in Chrome on your computer, find the app's +web view, and look for failed script requests. Debug builds make the web view +inspectable. + +The reliable fix is to run your app without the Vite dev server, so the +scripts come from your app's own address: + +::: code-group + +```bash [Laravel] +npm run build +php artisan serve +``` + +```bash [Rails] +# Rails builds the assets when the Vite dev server isn't running. +bin/rails server +``` + ::: -## Outline +You lose hot reload while you test this way. Run `npm run dev` again when you +go back to the browser. + +<!-- TODO: verify the Laravel + emulator + Vite dev server case end to end, and document a setup that keeps hot reload (for example `adb reverse tcp:5173 tcp:5173`, which only helps if the hot file uses localhost or 127.0.0.1, not [::1]) --> + +## Run on your phone + +Connect your phone over USB, then make it reach your computer either over +Wi-Fi or through the USB cable. Start by turning on USB debugging: + +1. On the phone, open **Settings** > **About phone** and tap **Build number** + seven times. This turns on **Developer options**. +2. In **Developer options**, turn on **USB debugging**. +3. Connect the phone with a cable and accept the prompt on the phone. + +With the phone connected, `npm run android` installs the app on it instead of +starting an emulator. You can also choose the phone in Android Studio's +toolbar and press **Run**. + +On the phone, `localhost` means the phone itself, and the `10.0.2.2` rewrite +only works on the emulator. Pick one of the two options below. + +### Option 1: over Wi-Fi + +Use your computer's address on your Wi-Fi network, for example +`192.168.1.20`. On a Mac, run `ipconfig getifaddr en0` to find it. + +1. Set the URL in `android/app/build.gradle.kts`: + + ```kotlin + buildConfigField("String", "BASE_URL", "\"http://192.168.1.20:8000\"") + ``` + +2. Allow plain HTTP to that address in + `android/app/src/debug/res/xml/network_security_config.xml`. Without this + line, Android blocks the request: + + ```xml + <domain includeSubdomains="false">192.168.1.20</domain> + ``` -1. Create a Hotwire Native Android app (Gradle). -2. Point its navigation at your app's URL. -3. Add a `path-configuration` JSON to control which routes present as modals, - pushes, etc. -4. Register the bridge components your web pages use (`form`, `menu`, …) if any. +3. Start your server so it accepts connections from the network. The same + Vite caveat applies as on the emulator, so run it without the Vite dev + server: + + ::: code-group + + ```bash [Laravel] + npm run build + php artisan serve --host=0.0.0.0 + ``` + + ```bash [Rails] + bin/rails server -b 0.0.0.0 + ``` + + ::: + +### Option 2: through the USB cable + +`adb reverse` forwards a port on the phone to the same port on your computer. +`adb` comes with the Android SDK, in its `platform-tools` directory. Use the +port your dev server listens on: + +```bash +adb reverse tcp:8000 tcp:8000 +``` + +Now `localhost:8000` on the phone reaches your computer. Keep the `localhost` +URL, but turn off the emulator rewrite in +`android/app/src/main/kotlin/dev/inertianative/app/Urls.kt`: + +```kotlin +val base: String = BuildConfig.BASE_URL +``` + +The forward lasts until you unplug the phone, so run `adb reverse` again after +you reconnect. The Vite caveat applies here too: if your page loads scripts +from the Vite dev server, build the assets and run your app without it. + +## Change the name, bundle ID, or URL + +`init` wrote your answers into a few places. Change them there: + +| What | Where | +| --- | --- | +| Name under the icon | `app_name` in `android/app/src/main/res/values/strings.xml` | +| Application ID (bundle ID) | `applicationId` in `android/app/build.gradle.kts` | +| URL the app loads | `BASE_URL` in `android/app/build.gradle.kts` | + +`init` also put the name in `android/settings.gradle.kts`. That's only the +Gradle project's name, and users don't see it. + +Before you ship, point release builds at your production site. Set the URL per +build type in `android/app/build.gradle.kts`. A value in a build type wins over +the one in `defaultConfig`: + +```kotlin +buildTypes { + debug { + buildConfigField("String", "BASE_URL", "\"http://localhost:8000\"") + } + release { + buildConfigField("String", "BASE_URL", "\"https://acme.example\"") + // keep the existing release settings + } +} +``` + +You can also run `npx inertia-native init android --force` with new values. It +recreates `android/` from scratch, so it deletes any changes you made there. + +## Path configuration + +Path configuration is the set of rules that decides how each URL opens, for +example as a modal. [How it works](/guide/how-it-works#path-configuration) +explains the format. + +The app reads it from two places: + +1. `android/app/src/main/assets/json/path-configuration.json`, bundled in the + app. +2. `/inertia-native/path-configuration/android_v1.json` on your server, if you + serve it. + +When the app starts, it applies the copy it saved from your server last time, +or the bundled file if it has no saved copy. Then it downloads a fresh copy. A +downloaded copy replaces the bundled rules completely. If the download fails, +for example with a 404, the app keeps what it has. + +The Android file starts with a rule for every URL (`.*`). It tells the app to +show web pages in `WebFragment`, with pull to refresh turned on. Keep that rule +first. + +### Serve it from your app + +Serving the file lets you change navigation with a deploy instead of an app +update. Add a route that returns the same JSON as the bundled file: + +::: code-group + +```php [Laravel] +// routes/web.php +Route::get('/inertia-native/path-configuration/android_v1.json', fn () => [ + 'rules' => [ + [ + 'patterns' => ['.*'], + 'properties' => [ + 'context' => 'default', + 'uri' => 'hotwire://fragment/web', + 'fallback_uri' => 'hotwire://fragment/web', + 'pull_to_refresh_enabled' => true, + ], + ], + [ + 'patterns' => ['/new$', '/edit$'], + 'properties' => ['context' => 'modal', 'pull_to_refresh_enabled' => false], + ], + [ + 'patterns' => ['^/$'], + 'properties' => ['presentation' => 'clear_all'], + ], + ], +]); +``` + +```ruby [Rails] +# config/routes.rb +get "inertia-native/path-configuration/android_v1", to: "path_configurations#android" + +# app/controllers/path_configurations_controller.rb +# ActionController::Base skips your app-wide filters, such as sign-in checks. +class PathConfigurationsController < ActionController::Base + def android + render json: { + settings: {}, + rules: [ + { + patterns: [".*"], + properties: { + context: "default", + uri: "hotwire://fragment/web", + fallback_uri: "hotwire://fragment/web", + pull_to_refresh_enabled: true + } + }, + { patterns: ["/new$", "/edit$"], properties: { context: "modal", pull_to_refresh_enabled: false } }, + { patterns: ["^/$"], properties: { presentation: "clear_all" } } + ] + } + end +end +``` -::: info Work in progress -A full step-by-step walkthrough is being written. For now, the demo app is the -source of truth. ::: + +If you also serve the iOS file, add this action to the same controller. The +[iOS page](/native/ios#serve-it-from-your-app) has the other half. + +Keep these points in mind: + +- **Include every rule.** The server copy replaces the bundled file, so start + from a copy of it. +- **Keep the route public.** The app downloads the file on launch, before + anyone signs in. +- **Leave out `settings` in PHP.** PHP turns an empty array into `[]` instead + of `{}`, and Android can't read `[]` there. The key is optional, so skip it + unless you have settings to send. +- **Relaunch to see changes.** The app downloads the file when it starts. + +## Bridge components + +The native halves of bridge components live in +`android/app/src/main/kotlin/dev/inertianative/app/bridge/`, and +`MainApplication.kt` registers them at launch: + +```kotlin +Hotwire.registerBridgeComponents( + BridgeComponentFactory("alert", ::AlertComponent), + BridgeComponentFactory("button", ::ButtonComponent), + BridgeComponentFactory("form", ::FormComponent), + BridgeComponentFactory("menu", ::MenuComponent), + BridgeComponentFactory("overflow-menu", ::OverflowMenuComponent) +) +``` + +To add a component, put its Kotlin file in the `bridge/` directory and add it +to this list. Set the file's first line to +`package dev.inertianative.app.bridge`. If it uses resources, import +`dev.inertianative.app.R`. Components from the +[registry](https://github.com/inertia-native/bridge-components) mark both +lines for you to replace. See [Bridge components](/components/overview) for +the web side. + +## Debugging + +Debug builds make the web view inspectable. Open `chrome://inspect` in Chrome +on your computer and pick the app's web view. Hotwire Native's own logs show up +in Android Studio's **Logcat** in debug builds. + +To log the messages between your page and the app, turn on `debug` in your +entrypoint. See [Debugging](/guide/navigation#debugging). From ae6d16f20ec4041e7fb3732203668806ca938595 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Thu, 8 Oct 2026 18:22:08 +0300 Subject: [PATCH 04/17] docs: add FAQ --- docs/guide/faq.md | 118 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/guide/faq.md diff --git a/docs/guide/faq.md b/docs/guide/faq.md new file mode 100644 index 0000000..c39d6f9 --- /dev/null +++ b/docs/guide/faq.md @@ -0,0 +1,118 @@ +# FAQ + +Answers to the questions people ask most before they try Inertia Native. Facts +about other projects come from their own sites and were checked in October +2026. + +## How does it compare to other options? + +Each of these tools gets an app into the App Store and Google Play. They differ +in where your screens come from and how much of your current app you reuse. +Inertia Native keeps your server and your Inertia pages as they are, loads them +in native screens, and works with any backend. + +### Capacitor + +[Capacitor](https://capacitorjs.com/docs) runs a web app inside a native +project, and plugins give your JavaScript access to native APIs. It bundles +your built web assets, starting from an `index.html`, into the app. Its +`server.url` option loads a remote site instead, but the +[config docs](https://capacitorjs.com/docs/config) say it's meant for live +reload and "not intended for use in production." + +An Inertia app needs your server to answer every page visit, so it doesn't fit +the bundled model without changes. Inertia Native loads pages from your server, +and each visit opens a native screen. + +### NativePHP Mobile + +[NativePHP Mobile](https://nativephp.com/) runs your Laravel app on the phone +itself, with a PHP runtime embedded in the app, so the app needs no server. +Since version 4 it can turn Blade views into SwiftUI and Jetpack Compose views, +and it still supports showing HTML in a web view, which is where Inertia apps +run. Its core is free and MIT-licensed, and some plugins are paid. + +Choose it when you want the app to work on the device without your server. +Inertia Native does the opposite: your Laravel app keeps running on your +server, and the phone shows its pages. Releasing a change means deploying your +server, not shipping a new app build. + +### Ruby Native + +[Ruby Native](https://rubynative.com/) is a paid service for Rails apps, built +on Hotwire Native. You add its gem and a configuration file, and its cloud +builds compile, sign, and submit the apps, so you don't need Xcode or Android +Studio. Its site says it works with any Rails frontend, Inertia included. +Plans start at $299 per app per year. + +Inertia Native is free and open source, works with any backend, and gives you +the native projects as plain Xcode and Gradle code that you build yourself. + +### React Native and Expo + +[React Native](https://reactnative.dev/) renders React components to native +platform UI, and [Expo](https://docs.expo.dev/get-started/introduction/) is a +framework and set of tools around it. With either one you write a second app +with its own screens, which usually calls your backend through an API. + +Pick React Native when most of your screens need to be fully native. Inertia +Native reuses the pages you already have, and you can still add native screens +and [bridge components](/components/overview) where they matter. + +## Will Apple accept my app? + +Apple reviews every app on its own, so nobody can promise approval. The +approach has a track record: 37signals builds its +[Basecamp](https://apps.apple.com/us/app/basecamp-project-management/id1015603248) +and [HEY](https://apps.apple.com/us/app/hey-email/id1506603805) apps on +[Turbo Native](https://dev.37signals.com/speeding-up-mobile-development-with-turbo/) +and [Strada](https://dev.37signals.com/announcing-strada/), the two libraries +that [became Hotwire Native](https://dev.37signals.com/announcing-hotwire-native/). +Both apps are in the App Store and Google Play. + +The guideline to read is +[4.2 Minimum Functionality](https://developer.apple.com/app-store/review/guidelines/#minimum-functionality): +"Your app should include features, content, and UI that elevate it beyond a +repackaged website." Native navigation helps. So do hiding your site's own +navigation in the app (see [Feel native](/guide/feel-native)) and adding +native features where your users benefit from them. + +## Does server-side rendering work? + +Yes, for navigation. From version 1.1, `initInertiaNative()` does nothing when +there's no `window`, so an entrypoint that also runs on the server works +unchanged. +<!-- TODO: confirm version --> + +In version 1.0, wrap the call yourself: + +```js +if (typeof window !== 'undefined') initInertiaNative() +``` + +`useBridgeComponent` reads `window` while a component renders, in all three +framework bindings. A page that uses a bridge component fails to render on the +server. + +<!-- TODO: update once useBridgeComponent is SSR-safe (react.js useState initializer, vue.js ref(), svelte.js readable() all read window during render) --> + +## Which Inertia versions does it support? + +Inertia Native needs Inertia 3 (`@inertiajs/core` 3.0 or later) with React, +Vue, or Svelte. Version 1.0 dropped support for Inertia 2. See +[Installation](/guide/installation#requirements) for the full list. + +## Does it work offline? Does it support push notifications? + +Neither is built in yet. The app loads every page from your server, so it +needs a network connection. The shells from `init` don't include push +notifications. Both are native features that you can add to the projects in +`ios/` and `android/` yourself. + +## Do I need to know Swift or Kotlin? + +Not to get started. `init` creates both native projects, and +`npm run ios` and `npm run android` build and run them. You open the native +code to change the app's name, icon, or URL, or to add a native component. The +[iOS](/native/ios) and [Android](/native/android) pages show where each of +those lives. From ff7a9e579c09240026cec4885cdaeef6f3ceb7a5 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Thu, 8 Oct 2026 18:22:11 +0300 Subject: [PATCH 05/17] docs: point newcomers to the quick start --- docs/guide/installation.md | 56 ++++++++++++++++++++++++++++++-------- docs/guide/introduction.md | 49 +++++++++++++++++++++------------ 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/docs/guide/installation.md b/docs/guide/installation.md index a793557..1daf99e 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -1,15 +1,23 @@ # Installation +Most apps should start with `npx inertia-native init`, which installs the +package, sets it up, and creates the native apps. The +[quick start](/guide/quick-start) walks through it. This page covers the +package on its own: what it needs and how to set it up by hand. + ```bash npm add inertia-native ``` ## Requirements +The package needs Inertia 3. The framework packages are optional peers, so you +only need the one your app uses: + - `@inertiajs/core` >= 3.0 -- `react` >= 18 — only for the `inertia-native/react` entry -- `vue` >= 3.0 — only for the `inertia-native/vue` entry -- `svelte` >= 4.0 — only for the `inertia-native/svelte` entry +- `react` >= 18, only for the `inertia-native/react` entry +- `vue` >= 3.0, only for the `inertia-native/vue` entry +- `svelte` >= 4.0, only for the `inertia-native/svelte` entry ## Setup @@ -20,24 +28,50 @@ entrypoint: import { createInertiaApp } from '@inertiajs/react' import { initInertiaNative } from 'inertia-native' -const isNativeApp = !!window.webkit?.messageHandlers?.turbo -initInertiaNative({ debug: import.meta.env.DEV || isNativeApp }) +initInertiaNative() createInertiaApp({ /* ... */ }) ``` -That's all that's needed for native navigation (push/pop/replace/restore, -modals, forms, error screens, pull-to-refresh). See the [API](/reference/api) -for the available options. +That's all native navigation needs: new screens, going back, modals, forms, +error screens, and pull to refresh. In a regular browser the call does +nothing. + +To log the messages between your page and the native app while you develop, +pass `debug`: + +```js +initInertiaNative({ debug: import.meta.env.DEV }) +``` + +See the [API](/reference/api) for every option. + +### Server-side rendering -To set up the native iOS and Android shells that load your app, see -[Native apps → iOS](/native/ios) and [Android](/native/android). +From version 1.1, `initInertiaNative()` does nothing on the server, where +there's no `window`. In version 1.0, guard the call: +<!-- TODO: confirm version --> + +```js +if (typeof window !== 'undefined') initInertiaNative() +``` + +The [FAQ](/guide/faq#does-server-side-rendering-work) covers bridge components +and server-side rendering. + +## Native apps + +The package is the web half. The native iOS and Android apps that load your +pages are separate projects, and `npx inertia-native init` creates them. See +[iOS](/native/ios) and [Android](/native/android). ## Upgrading from 0.1.0-beta +Version 1.0 renamed the setup function and dropped Inertia 2: + - `initHotwireNative` is now `initInertiaNative`. - `@inertiajs/core` 2.x is no longer supported. - The package was published as `inertia-hotwire-native` before `0.1.0-beta.2`; replace that dependency with `inertia-native`. -Next: [Navigation](/guide/navigation). +Next: [Bridge components](/components/overview). diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 1ce16fd..8ac6201 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -1,9 +1,19 @@ # Introduction -[`inertia-native`](https://www.npmjs.com/package/inertia-native) -lets an [Inertia.js](https://inertiajs.com) application run inside -[Hotwire Native](https://native.hotwired.dev) (iOS & Android) and behave like a -native app. +[`inertia-native`](https://www.npmjs.com/package/inertia-native) turns an +[Inertia.js](https://inertiajs.com) app into iOS and Android apps. Your server +keeps rendering every page, and each page visit opens as a native screen, with +native navigation, modals, error screens, and pull to refresh. It works with +React, Vue, and Svelte, and with any backend, such as Laravel or Rails. + +It's built on [Hotwire Native](https://native.hotwired.dev), the open-source +framework from 37signals for apps that show web pages in native screens. You +don't need to know Hotwire Native to use it. + +::: tip New here? +The [quick start](/guide/quick-start) takes you from your Inertia app to a +native app running in a simulator. +::: ::: info Unofficial This is a community-built integration. It is **not** an official Inertia.js or @@ -12,21 +22,25 @@ Hotwire project. ## How it works -Hotwire Native injects a `turbo.js` script into every web view that expects a -`window.Turbo` object to drive. This package installs a small shim that maps the -native adapter protocol onto Inertia's router, so Inertia pages push, pop, and -restore as native screens — plus a web bridge so Inertia pages can use native -bridge components (submit buttons, menus, etc.). +Hotwire Native was built for Turbo, and it expects to find Turbo on every page. +This package stands in for Turbo and translates its messages to Inertia's +router. When Inertia visits a new page, the native app opens a new screen for +it. The package also lets your pages use bridge components, which draw native +UI such as buttons in the navigation bar. + +In a regular browser no native app is listening, so the package does nothing +and Inertia navigates as usual. -In a regular browser it stays inert: with no native adapter connected, Inertia -navigates exactly as usual. +[How it works](/guide/how-it-works) explains the native side step by step. ## Entry points -- **Framework-agnostic core** (`inertia-native`) — peer-depends on - `@inertiajs/core`. Handles all navigation. -- **Bridge component bindings** — optional, each peer-depends on its framework - and provides `useBridgeComponent`: +The package has one core entry and three optional ones: + +- **Framework-agnostic core** (`inertia-native`) peer-depends on + `@inertiajs/core` and handles all navigation. +- **Bridge component bindings** are optional. Each one peer-depends on its + framework and provides `useBridgeComponent`: - `inertia-native/react` - `inertia-native/vue` - `inertia-native/svelte` @@ -35,9 +49,10 @@ navigates exactly as usual. | Repository | What it is | | --- | --- | -| [inertia-native](https://github.com/inertia-native/inertia-native) | The npm package | +| [inertia-native](https://github.com/inertia-native/inertia-native) | The npm package, including the native app templates that `init` uses | +| [bridge-components](https://github.com/inertia-native/bridge-components) | Ready-made bridge components to copy into your app | | [demo-rails](https://github.com/inertia-native/demo-rails) | Web / Rails demo app | | [demo-ios](https://github.com/inertia-native/demo-ios) | iOS Hotwire Native shell | | [demo-android](https://github.com/inertia-native/demo-android) | Android Hotwire Native shell | -Next: [Installation](/guide/installation). +Next: [Quick start](/guide/quick-start). From 4fdfd08c5150ef29fee3eae9125a0554e8f8546f Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Thu, 8 Oct 2026 18:22:15 +0300 Subject: [PATCH 06/17] docs: reorder sidebar around the quick start --- docs/.vitepress/config.mts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 700c612..e4242af 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -10,6 +10,7 @@ export default defineConfig({ themeConfig: { nav: [ { text: 'Guide', link: '/guide/introduction' }, + { text: 'Quick start', link: '/guide/quick-start' }, { text: 'API', link: '/reference/api' }, { text: 'Links', @@ -27,8 +28,11 @@ export default defineConfig({ text: 'Guide', items: [ { text: 'Introduction', link: '/guide/introduction' }, - { text: 'Installation', link: '/guide/installation' }, + { text: 'Quick start', link: '/guide/quick-start' }, + { text: 'How it works', link: '/guide/how-it-works' }, + { text: 'Feel native', link: '/guide/feel-native' }, { text: 'Navigation', link: '/guide/navigation' }, + { text: 'Manual installation', link: '/guide/installation' }, ], }, { @@ -50,6 +54,10 @@ export default defineConfig({ text: 'Reference', items: [{ text: 'API', link: '/reference/api' }], }, + { + text: 'Help', + items: [{ text: 'FAQ', link: '/guide/faq' }], + }, ], socialLinks: [ From bb36481163402d8eb1844b387f46f507947540eb Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Thu, 8 Oct 2026 18:22:18 +0300 Subject: [PATCH 07/17] docs: stop suggesting iOS-only app detection --- docs/reference/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/api.md b/docs/reference/api.md index 0591f7e..ff23638 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -104,4 +104,4 @@ The package installs and reads these on `window`: | --- | --- | --- | | `window.Turbo` | `initInertiaNative()` | Shim that `turbo.js` drives for navigation. Internal. | | `window.HotwireNative.web` | `initInertiaNative()` | Web side of the bridge (`send`, `supportsComponent`, `removeCallback`, …). The name is Hotwire Native's bridge protocol. | -| `window.webkit.messageHandlers.turbo` | WKWebView (iOS) | Feature-detect that the app runs inside the native shell. | +| `window.webkit.messageHandlers.turbo` | WKWebView (iOS) | Hotwire Native's message channel on iOS. Android has no `window.webkit`, so don't use it to detect the app: check the user agent for `Hotwire Native` instead (see [Feel native](/guide/feel-native#tell-the-app-apart-from-a-browser)). | From 4b503f77eb02d0bd1e3c64bcc6b18064aae1ddbe Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Thu, 8 Oct 2026 18:51:32 +0300 Subject: [PATCH 08/17] docs: switch Android docs to adb reverse --- docs/native/android.md | 144 ++++++++++++++++++++--------------------- 1 file changed, 72 insertions(+), 72 deletions(-) diff --git a/docs/native/android.md b/docs/native/android.md index 4c3ba5b..fe6ddbf 100644 --- a/docs/native/android.md +++ b/docs/native/android.md @@ -35,7 +35,7 @@ android/ │ │ │ │ configuration, bridge components │ │ │ ├── MainActivity.kt opens your URL on launch │ │ │ ├── WebFragment.kt the screen that shows a web page -│ │ │ ├── Urls.kt the URL, adjusted for the emulator +│ │ │ ├── Urls.kt the URL the app starts at │ │ │ └── bridge/ native halves of the alert, button, │ │ │ form, menu, and overflow-menu components │ │ └── res/values/strings.xml app name @@ -52,6 +52,10 @@ don't need to rename the package. `WebFragment.kt` adds a spinner to the toolbar while a form submits. +`Urls.kt` adds a trailing `/` to a URL without a path, so the app starts at +`http://localhost:8000/`. That's the form the web view reports, and starting +there avoids an extra redirect on launch. + ## Run on the emulator Run this from your app's root, with your dev server running: @@ -60,89 +64,106 @@ Run this from your app's root, with your dev server running: npm run android ``` -It builds and installs a debug build with Gradle, starts your first emulator -if none is running, waits for it to boot, and launches your app. It warns you -if your dev server doesn't answer. +It uses the device that's already connected, or starts your first emulator +and waits for it to boot. Then it builds and installs a debug build with +Gradle, forwards your dev server's ports to the device, and launches your app. +It warns you if your dev server doesn't answer. -To pick an emulator, pass its name after `--`: +Pass options after `--`: ```bash npm run android -- --avd Pixel_9 ``` -`emulator -list-avds` prints the names you can use. The `emulator` tool is in -the Android SDK's `emulator` directory. +| Option | What it does | +| --- | --- | +| `--avd <name>` | The emulator to use, started if needed. `emulator -list-avds` prints the names. The `emulator` tool is in the Android SDK's `emulator` directory. | +| `--device <serial>` | A connected device to use, by the serial that `adb devices` prints. Use it when more than one device is connected. | -You can also run from Android Studio. Open the `android/` directory, wait for -Gradle to finish syncing, choose an emulator in the toolbar, and press -**Run**. +### How the device reaches your computer -### How the emulator reaches your computer +Inside the emulator, `localhost` is the emulator itself, and the app loads your +URL as it is. `npm run android` fixes this with `adb reverse`, which forwards a +port on the device to the same port on your computer. It forwards your app's +port and the Vite dev server's port: -Inside the emulator, `localhost` is the emulator itself. Your computer is -`10.0.2.2`. In debug builds, `Urls.kt` rewrites a `localhost` or `127.0.0.1` -URL to `10.0.2.2`, so `http://localhost:8000` works without changes. +```text +✓ adb reverse tcp:8000, tcp:5173 (localhost on emulator-5554 reaches this machine) +``` -Android blocks plain HTTP by default. In debug builds, -`network_security_config.xml` allows it for `10.0.2.2`, `localhost`, and -`127.0.0.1` only. Release builds keep Android's default and need HTTPS. +After that, `http://localhost:8000` in the app reaches your dev server, and so +do the scripts that Vite serves at `http://127.0.0.1:5173`. The forward lasts +until the emulator shuts down. -### Blank screen on the emulator +Vite must listen on `127.0.0.1` for this to work, not on the IPv6 address +`[::1]`. [Quick start](/guide/quick-start#_2-start-your-dev-server) shows the +one-line Vite config change. `npm run android` warns you when it's missing. -If the app opens but the page stays blank or unstyled, the page's scripts and -styles probably didn't load. The app rewrites your URL, but it can't rewrite -the addresses inside your page. A layout that loads scripts from the Vite dev -server at a `localhost` address points the emulator at itself. Laravel works -this way: its pages load scripts from the address in `public/hot`, such as -`http://[::1]:5173`. +Android blocks plain HTTP by default. In debug builds, +`network_security_config.xml` allows it for `localhost`, `127.0.0.1`, and +`10.0.2.2` only. Release builds keep Android's default and need HTTPS. -To check, open `chrome://inspect` in Chrome on your computer, find the app's -web view, and look for failed script requests. Debug builds make the web view -inspectable. +### Run from Android Studio -The reliable fix is to run your app without the Vite dev server, so the -scripts come from your app's own address: +You can also run from Android Studio. Open the `android/` directory, wait for +Gradle to finish syncing, choose an emulator in the toolbar, and press +**Run**. + +Android Studio doesn't forward ports. Once per emulator boot, either run +`npm run android`, or forward the ports yourself with `adb`, which is in the +Android SDK's `platform-tools` directory: ::: code-group ```bash [Laravel] -npm run build -php artisan serve +adb reverse tcp:8000 tcp:8000 +adb reverse tcp:5173 tcp:5173 ``` ```bash [Rails] -# Rails builds the assets when the Vite dev server isn't running. -bin/rails server +adb reverse tcp:3000 tcp:3000 +adb reverse tcp:5173 tcp:5173 ``` ::: -You lose hot reload while you test this way. Run `npm run dev` again when you -go back to the browser. +If your Rails app uses `vite_rails`, its dev server port is in +`config/vite.json`, and it's `3036` by default. + +### "Error loading page" on the emulator -<!-- TODO: verify the Laravel + emulator + Vite dev server case end to end, and document a setup that keeps hot reload (for example `adb reverse tcp:5173 tcp:5173`, which only helps if the hot file uses localhost or 127.0.0.1, not [::1]) --> +The app shows this screen when your page doesn't load. Check these, in order: + +1. Your dev server is running, and the URL works in your browser. +2. The ports are forwarded. Run `npm run android` again, or the `adb reverse` + commands above. +3. Vite listens on `127.0.0.1`. If `npm run android` printed a warning about + `[::1]`, add the line it shows to your Vite config and restart the dev + server. + +To look closer, open `chrome://inspect` in Chrome on your computer, find the +app's web view, and check its console and network requests. Debug builds make +the web view inspectable. ## Run on your phone -Connect your phone over USB, then make it reach your computer either over -Wi-Fi or through the USB cable. Start by turning on USB debugging: +A phone connected over USB works like the emulator, including the port +forwarding. Turn on USB debugging first: 1. On the phone, open **Settings** > **About phone** and tap **Build number** seven times. This turns on **Developer options**. 2. In **Developer options**, turn on **USB debugging**. 3. Connect the phone with a cable and accept the prompt on the phone. -With the phone connected, `npm run android` installs the app on it instead of -starting an emulator. You can also choose the phone in Android Studio's -toolbar and press **Run**. +Then run `npm run android`. It installs the app on the phone and forwards the +ports, so `localhost` works without changes. If an emulator is running too, +pick the phone with `--device` and the serial from `adb devices`. Unplugging +the phone ends the forward, so run the command again after you reconnect. -On the phone, `localhost` means the phone itself, and the `10.0.2.2` rewrite -only works on the emulator. Pick one of the two options below. +### Over Wi-Fi instead -### Option 1: over Wi-Fi - -Use your computer's address on your Wi-Fi network, for example -`192.168.1.20`. On a Mac, run `ipconfig getifaddr en0` to find it. +Without a cable, the phone needs your computer's address on your Wi-Fi network, +for example `192.168.1.20`. On a Mac, run `ipconfig getifaddr en0` to find it. 1. Set the URL in `android/app/build.gradle.kts`: @@ -158,9 +179,9 @@ Use your computer's address on your Wi-Fi network, for example <domain includeSubdomains="false">192.168.1.20</domain> ``` -3. Start your server so it accepts connections from the network. The same - Vite caveat applies as on the emulator, so run it without the Vite dev - server: +3. Start your server so it accepts connections from the network. Vite's + `127.0.0.1` address isn't reachable over Wi-Fi, so run your app without + the Vite dev server: ::: code-group @@ -170,33 +191,12 @@ Use your computer's address on your Wi-Fi network, for example ``` ```bash [Rails] + # Rails builds the assets when the Vite dev server isn't running. bin/rails server -b 0.0.0.0 ``` ::: -### Option 2: through the USB cable - -`adb reverse` forwards a port on the phone to the same port on your computer. -`adb` comes with the Android SDK, in its `platform-tools` directory. Use the -port your dev server listens on: - -```bash -adb reverse tcp:8000 tcp:8000 -``` - -Now `localhost:8000` on the phone reaches your computer. Keep the `localhost` -URL, but turn off the emulator rewrite in -`android/app/src/main/kotlin/dev/inertianative/app/Urls.kt`: - -```kotlin -val base: String = BuildConfig.BASE_URL -``` - -The forward lasts until you unplug the phone, so run `adb reverse` again after -you reconnect. The Vite caveat applies here too: if your page loads scripts -from the Vite dev server, build the assets and run your app without it. - ## Change the name, bundle ID, or URL `init` wrote your answers into a few places. Change them there: From 9da22ca9a2be3e9b6536947b073caca579417bb2 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Thu, 8 Oct 2026 18:51:32 +0300 Subject: [PATCH 09/17] docs: add Vite host and email tips to quick start --- docs/guide/quick-start.md | 99 +++++++++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 26 deletions(-) diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index 01b1503..809f6b7 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -43,56 +43,90 @@ It asks a few questions. Press Enter to accept each default: - **Dev server URL**: the address the app loads. Use the same URL you open in your browser during development. -Then it changes four things in your project. The output looks like this: +Then it changes four things in your project. The output looks like this, with +the defaults accepted: ::: code-group ```text [Laravel] $ npx inertia-native init -? Platforms › both +? Platforms [both/ios/android] (both) › ✓ Found entrypoint resources/js/app.tsx -? App name › Acme Shop -? Bundle ID › com.example.acmeshop -? Dev server URL › http://localhost:8000 +? App name (Acme Shop) › +? Bundle ID (com.example.acmeshop) › +? Dev server URL (http://localhost:8000) › +› npm install inertia-native ✓ Added inertia-native to package.json (npm) ✓ Patched resources/js/app.tsx ✓ Created ios/ and android/ ✓ Added "ios" and "android" scripts to package.json -Next: start your dev server (composer run dev), then run: npm run ios +Next: start your dev server (composer run dev), then run: npm run ios (or npm run android) ``` ```text [Rails] $ npx inertia-native init -? Platforms › both -✓ Found entrypoint app/frontend/entrypoints/inertia.tsx -? App name › Acme Shop -? Bundle ID › com.example.acmeshop -? Dev server URL › http://localhost:3000 +? Platforms [both/ios/android] (both) › +✓ Found entrypoint app/javascript/entrypoints/inertia.tsx +? App name (Acme Shop) › +? Bundle ID (com.example.acmeshop) › +? Dev server URL (http://localhost:3000) › +› npm install inertia-native ✓ Added inertia-native to package.json (npm) -✓ Patched app/frontend/entrypoints/inertia.tsx +✓ Patched app/javascript/entrypoints/inertia.tsx ✓ Created ios/ and android/ ✓ Added "ios" and "android" scripts to package.json -Next: start your dev server (bin/dev), then run: npm run ios +Next: start your dev server (bin/dev), then run: npm run ios (or npm run android) ``` ::: -<!-- TODO: re-check the sample output against the released CLI --> - `init` finds your Inertia entrypoint by looking for `createInertiaApp(` in the usual places. It picks `http://localhost:8000` when it finds `artisan` and `http://localhost:3000` otherwise. If your app runs somewhere else, type that URL at the prompt. +`init` uses the package manager your lockfile belongs to, so its hints may say +`pnpm run ios` or `yarn run ios` instead of `npm run ios`. + `init` is safe to run again: it skips anything that's already done. See [What `init` did](#what-init-did) for the details, or [`init` options](#init-options) to run it without questions. ## 2. Start your dev server -Start your app the way you always do: +If you plan to run on Android, change one line of your Vite config first. The +Laravel and Rails starter kits serve scripts from `http://[::1]:5173`, an IPv6 +address that Android can't reach. Make Vite listen on `127.0.0.1` instead: + +::: code-group + +```ts [Laravel] +// vite.config.ts +export default defineConfig({ + // … + server: { + host: '127.0.0.1', // [!code ++] + // … + }, +}) +``` + +```ts [Rails] +// vite.config.ts +export default defineConfig(({ command }) => ({ + server: { host: "127.0.0.1" }, // [!code ++] + // … +})) +``` + +::: + +`npm run android` checks for this and prints the line to add if it's missing. +iOS works either way. + +Now start your app the way you always do: ::: code-group @@ -120,9 +154,10 @@ npm run android `npm run ios` builds the app, starts an iPhone simulator, installs the app, and opens it. `npm run android` builds the app, starts your first emulator if -none is running, installs the app, and opens it. The first build downloads -Hotwire Native and the other dependencies, so it takes longer than the ones -after it. +no device is connected, installs the app, and opens it. On Android it also +runs `adb reverse` for your dev server's ports, so `localhost` on the device +reaches your computer. The first build downloads Hotwire Native and the other +dependencies, so it takes longer than the ones after it. You should see your home page on a native screen. Try these: @@ -132,11 +167,21 @@ You should see your home page on a native screen. Try these: screen that slides up from the bottom. - Pull the page down to reload it. -::: warning Blank screen on the Android emulator? -Inside the emulator, `localhost` means the emulator itself. The app already -handles that for your URL, but if your page loads scripts from the Vite dev -server at a `localhost` address, the emulator can't reach them. See -[Blank screen on the emulator](/native/android#blank-screen-on-the-emulator). +If Android shows **Error loading page**, see +[the checklist](/native/android#error-loading-page-on-the-emulator). + +::: tip Laravel starter kit: stuck on "Email verification"? +The starter kit asks new users to verify their email. In development it +doesn't send the email: it writes the link to `storage/logs/laravel.log`, and +the app has no address bar to open it. Use an account that's already verified +instead. `php artisan db:seed` creates `test@example.com` with the password +`password`. To verify an account you've already registered, run this and then +pull down to reload the page: + +```bash +php artisan tinker --execute="App\Models\User::where('email', 'you@example.com')->update(['email_verified_at' => now()])" +``` + ::: To run on your own phone, see [iOS](/native/ios#run-on-your-iphone) and @@ -173,7 +218,7 @@ createInertiaApp({ ``` ```js [Rails] -// app/frontend/entrypoints/inertia.tsx (or inertia.ts, inertia.js) +// app/javascript/entrypoints/inertia.tsx (or app/frontend/entrypoints/…) import { createInertiaApp } from '@inertiajs/react' import { initInertiaNative } from 'inertia-native' // [!code ++] @@ -200,7 +245,9 @@ it prints these two lines for you to add yourself. - `ios/`: an Xcode project. Without the script, open `ios/App.xcodeproj` in Xcode, pick a simulator at the top of the window, and press **Run**. - `android/`: a Gradle project. Without the script, open the `android/` - directory in Android Studio, pick an emulator, and press **Run**. + directory in Android Studio, pick an emulator, and press **Run**. You also + need to [forward your dev server's ports](/native/android#run-from-android-studio) + once per emulator boot. Both are small and readable. [iOS](/native/ios) and [Android](/native/android) explain each file and how to change the name, bundle ID, and URL later. From 1ad9e6af2c507e8e8ac63b9239916a5e19d4ad20 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Thu, 8 Oct 2026 18:51:32 +0300 Subject: [PATCH 10/17] docs: apply the iPhone Vite caveat to both kits --- docs/native/ios.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/native/ios.md b/docs/native/ios.md index 7b9a582..9e56362 100644 --- a/docs/native/ios.md +++ b/docs/native/ios.md @@ -93,9 +93,9 @@ The app's `Info.plist` sets `NSAllowsLocalNetworking`, which lets iOS load plain HTTP from local network addresses like this one. Your server must accept connections from the network, and the page's scripts -must come from an address the phone can reach. A setup that loads scripts from -the Vite dev server at `localhost` leaves the page blank on the phone, and -Laravel works that way. The reliable option is to run your app without the +must come from an address the phone can reach. The Laravel and Rails starter +kits load scripts from the Vite dev server at a local address, so the page +won't load on the phone. The reliable option is to run your app without the Vite dev server: ::: code-group From 8b2b37cb61d16fc0840a7e0e83501da9f926b48d Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Thu, 8 Oct 2026 22:43:53 +0300 Subject: [PATCH 11/17] docs: cover device choice, open and Vite host --- docs/guide/quick-start.md | 81 ++++++++++++++++++++++++++++----------- docs/native/android.md | 58 +++++++++++++++++++--------- docs/native/ios.md | 28 ++++++++++---- 3 files changed, 118 insertions(+), 49 deletions(-) diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index 809f6b7..1e23b15 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -42,9 +42,12 @@ It asks a few questions. Press Enter to accept each default: The default `com.example.…` is fine for trying things out. - **Dev server URL**: the address the app loads. Use the same URL you open in your browser during development. +- **Vite host**, if you build for Android: whether `init` may change one line + of your Vite config so that Android can load your scripts. + [Step 2](#_2-start-your-dev-server) explains why. -Then it changes four things in your project. The output looks like this, with -the defaults accepted: +Then it changes your project. The output looks like this, with the defaults +accepted: ::: code-group @@ -55,9 +58,11 @@ $ npx inertia-native init ? App name (Acme Shop) › ? Bundle ID (com.example.acmeshop) › ? Dev server URL (http://localhost:8000) › +? Set server.host to 127.0.0.1 in vite.config.ts, so Android can load scripts from Vite? (Y/n) › › npm install inertia-native ✓ Added inertia-native to package.json (npm) ✓ Patched resources/js/app.tsx +✓ Set server.host to 127.0.0.1 in vite.config.ts ✓ Created ios/ and android/ ✓ Added "ios" and "android" scripts to package.json @@ -71,9 +76,11 @@ $ npx inertia-native init ? App name (Acme Shop) › ? Bundle ID (com.example.acmeshop) › ? Dev server URL (http://localhost:3000) › +? Set server.host to 127.0.0.1 in vite.config.ts, so Android can load scripts from Vite? (Y/n) › › npm install inertia-native ✓ Added inertia-native to package.json (npm) ✓ Patched app/javascript/entrypoints/inertia.tsx +✓ Set server.host to 127.0.0.1 in vite.config.ts ✓ Created ios/ and android/ ✓ Added "ios" and "android" scripts to package.json @@ -96,9 +103,10 @@ URL at the prompt. ## 2. Start your dev server -If you plan to run on Android, change one line of your Vite config first. The -Laravel and Rails starter kits serve scripts from `http://[::1]:5173`, an IPv6 -address that Android can't reach. Make Vite listen on `127.0.0.1` instead: +If you build for Android, `init` asked to change one line of your Vite config. +The Laravel and Rails starter kits serve scripts from `http://[::1]:5173`, an +IPv6 address that Android can't reach. The change makes Vite listen on +`127.0.0.1` instead: ::: code-group @@ -123,8 +131,13 @@ export default defineConfig(({ command }) => ({ ::: -`npm run android` checks for this and prints the line to add if it's missing. -iOS works either way. +If you answered no, or `init` couldn't change your config safely, make the +change by hand. `init` printed the line to add, and `npm run android` checks +for it and prints the line again if it's missing. iOS works either way. + +Rails apps on `vite_rails` (the ones with `config/vite.json`) don't need this +change, so `init` doesn't ask. Rails serves their scripts from its own +address. Now start your app the way you always do: @@ -152,12 +165,22 @@ npm run ios npm run android ``` -`npm run ios` builds the app, starts an iPhone simulator, installs the app, -and opens it. `npm run android` builds the app, starts your first emulator if -no device is connected, installs the app, and opens it. On Android it also -runs `adb reverse` for your dev server's ports, so `localhost` on the device -reaches your computer. The first build downloads Hotwire Native and the other -dependencies, so it takes longer than the ones after it. +`npm run ios` builds the app, installs it on the simulator that's running, and +opens it. `npm run android` does the same on the emulator or phone that's +connected. On Android it also runs `adb reverse` for your dev server's ports, +so `localhost` on the device reaches your computer. The first build downloads +Hotwire Native and the other dependencies, so it takes longer than the ones +after it. + +If nothing is running, `npm run ios` asks which iPhone simulator to start and +selects the newest one. `npm run android` starts your emulator, or asks which +one when you have several. If more than one simulator or device is running, +it asks which one to use. To see them all, add `--list`: + +```bash +npm run ios -- --list +npm run android -- --list +``` You should see your home page on a native screen. Try these: @@ -195,8 +218,8 @@ shows how to hide them, close a modal after a form, and add a native button. ## What `init` did -`init` made four changes. You can make them by hand instead if you prefer to -see each step. +`init` made up to five changes. You can make them by hand instead if you +prefer to see each step. ### The package and two lines of JavaScript @@ -238,16 +261,30 @@ nothing, so your website keeps working as before. The If `init` can't find your entrypoint, or finds a file it can't safely change, it prints these two lines for you to add yourself. +### The Vite host + +If you build for Android and answered yes, `init` set `server.host` to +`127.0.0.1` in your Vite config, as shown in +[step 2](#_2-start-your-dev-server). It adds the line to your `server` options, +or adds `server` if your config doesn't have it, and keeps your quotes and +indentation. + +`init` leaves the file alone when it already sets `server.host`, or when it +can't tell where the line goes, for example when `defineConfig` gets a function +with a body. Then it prints the line for you to add. + ### The native projects `init` created two native projects next to your app code: -- `ios/`: an Xcode project. Without the script, open `ios/App.xcodeproj` in - Xcode, pick a simulator at the top of the window, and press **Run**. -- `android/`: a Gradle project. Without the script, open the `android/` - directory in Android Studio, pick an emulator, and press **Run**. You also - need to [forward your dev server's ports](/native/android#run-from-android-studio) - once per emulator boot. +- `ios/`: an Xcode project. To run it from Xcode instead of the script, run + `npx inertia-native open ios`, pick a simulator at the top of the window, + and press **Run**. +- `android/`: a Gradle project. To run it from Android Studio instead of the + script, run `npx inertia-native open android`, pick an emulator, and press + **Run**. You also need to + [forward your dev server's ports](/native/android#run-from-android-studio) + once per emulator boot. `open android` prints the commands for it. Both are small and readable. [iOS](/native/ios) and [Android](/native/android) explain each file and how to change the name, bundle ID, and URL later. @@ -286,7 +323,7 @@ npx inertia-native init ios --name "Acme Shop" --bundle-id com.acme.shop --url h | `--entrypoint` | Path to your Inertia entrypoint, if `init` picks the wrong file. | | `--skip-install` | Don't add the npm package. | | `--force` | Replace existing `ios/` and `android/` directories. This deletes changes you made in them. | -| `--yes` | Accept every default without asking. | +| `--yes` | Accept every default without asking, including the Vite host change for Android. | `init` only asks questions in an interactive terminal. Elsewhere it uses the flags and defaults. diff --git a/docs/native/android.md b/docs/native/android.md index fe6ddbf..d904331 100644 --- a/docs/native/android.md +++ b/docs/native/android.md @@ -64,12 +64,20 @@ Run this from your app's root, with your dev server running: npm run android ``` -It uses the device that's already connected, or starts your first emulator -and waits for it to boot. Then it builds and installs a debug build with -Gradle, forwards your dev server's ports to the device, and launches your app. -It warns you if your dev server doesn't answer. +It builds and installs a debug build with Gradle, forwards your dev server's +ports to the device, and launches your app. It warns you if your dev server +doesn't answer. -Pass options after `--`: +It picks the device this way: + +- **One device or emulator is connected**: it uses that one. +- **Several are connected**: it asks which one. Outside an interactive + terminal, for example in a script, it stops and lists them instead. +- **None is connected**: it starts your emulator and waits for it to boot. If + you have several emulators, it asks which one to start, or stops and lists + them outside an interactive terminal. + +To pick one yourself, pass an option after `--`: ```bash npm run android -- --avd Pixel_9 @@ -77,8 +85,9 @@ npm run android -- --avd Pixel_9 | Option | What it does | | --- | --- | -| `--avd <name>` | The emulator to use, started if needed. `emulator -list-avds` prints the names. The `emulator` tool is in the Android SDK's `emulator` directory. | -| `--device <serial>` | A connected device to use, by the serial that `adb devices` prints. Use it when more than one device is connected. | +| `--avd <name>` | The emulator to use, by its AVD name. It's started if needed. | +| `--device <serial>` | A connected device or running emulator to use, by its serial, such as `emulator-5554`. | +| `--list` | List the connected devices and your emulators, then exit. | ### How the device reaches your computer @@ -95,9 +104,11 @@ After that, `http://localhost:8000` in the app reaches your dev server, and so do the scripts that Vite serves at `http://127.0.0.1:5173`. The forward lasts until the emulator shuts down. -Vite must listen on `127.0.0.1` for this to work, not on the IPv6 address -`[::1]`. [Quick start](/guide/quick-start#_2-start-your-dev-server) shows the -one-line Vite config change. `npm run android` warns you when it's missing. +Vite must listen on `127.0.0.1` for this to work. When it listens on the IPv6 +address `[::1]`, the page loads its scripts from `http://[::1]:5173`, which +the device can't reach. `init` offers to make the one-line Vite config change +that [Quick start](/guide/quick-start#_2-start-your-dev-server) shows, and +`npm run android` warns you when it's missing. Android blocks plain HTTP by default. In debug builds, `network_security_config.xml` allows it for `localhost`, `127.0.0.1`, and @@ -105,13 +116,21 @@ Android blocks plain HTTP by default. In debug builds, ### Run from Android Studio -You can also run from Android Studio. Open the `android/` directory, wait for -Gradle to finish syncing, choose an emulator in the toolbar, and press -**Run**. +You can also run from Android Studio. Run this to open the `android/` +directory in it: + +```bash +npx inertia-native open android +``` + +Wait for Gradle to finish syncing, choose an emulator in the toolbar, and +press **Run**. On Linux, `open` needs Android Studio's `studio` or `studio.sh` +launcher on your `PATH`; otherwise it prints the directory to open. -Android Studio doesn't forward ports. Once per emulator boot, either run -`npm run android`, or forward the ports yourself with `adb`, which is in the -Android SDK's `platform-tools` directory: +Android Studio doesn't forward ports, so `open android` prints the `adb reverse` +commands for your app's ports. Once per emulator boot, either run +`npm run android`, or run those commands. `adb` is in the Android SDK's +`platform-tools` directory: ::: code-group @@ -156,9 +175,10 @@ forwarding. Turn on USB debugging first: 3. Connect the phone with a cable and accept the prompt on the phone. Then run `npm run android`. It installs the app on the phone and forwards the -ports, so `localhost` works without changes. If an emulator is running too, -pick the phone with `--device` and the serial from `adb devices`. Unplugging -the phone ends the forward, so run the command again after you reconnect. +ports, so `localhost` works without changes. If an emulator is running too, it +asks which one to use. You can also pick the phone with `--device` and the +serial that `npm run android -- --list` prints. Unplugging the phone ends the +forward, so run the command again after you reconnect. ### Over Wi-Fi instead diff --git a/docs/native/ios.md b/docs/native/ios.md index 9e56362..2657181 100644 --- a/docs/native/ios.md +++ b/docs/native/ios.md @@ -45,20 +45,32 @@ Run this from your app's root, with your dev server running: npm run ios ``` -It builds the app into `ios/build`, picks the simulator that's already -running or the newest iPhone, opens the Simulator app, then installs and +It builds the app into `ios/build`, opens the Simulator app, then installs and launches your app. It warns you if your dev server doesn't answer. -To pick a simulator, pass its name after `--`: +It picks the simulator this way: + +- **One simulator is running**: it uses that one. +- **Several are running**: it asks which one. Outside an interactive + terminal, for example in a script, it stops and lists them instead. +- **None is running**: it asks which iPhone simulator to start, with the + newest one selected. Outside an interactive terminal, it starts the newest + iPhone. + +To pick a simulator yourself, pass its name or UDID after `--`: ```bash -npm run ios -- --device "iPhone 16 Pro" +npm run ios -- --device "iPhone 17 Pro" ``` -`xcrun simctl list devices available` lists the names you can use. +| Option | What it does | +| --- | --- | +| `--device <name>` | The simulator to use, by name or UDID. It's started if needed. | +| `--list` | List the running and available simulators, then exit. | -You can also run from Xcode. Open `ios/App.xcodeproj`, choose a simulator in -the run destination menu at the top of the window, and press **Run** (⌘R). +You can also run from Xcode. Run `npx inertia-native open ios` to open +`ios/App.xcodeproj`, choose a simulator in the run destination menu at the top +of the window, and press **Run** (⌘R). The simulator shares your Mac's network, so `http://localhost:8000` or `http://localhost:3000` reaches your dev server without changes. @@ -69,7 +81,7 @@ The simulator shares your Mac's network, so `http://localhost:8000` or these steps: 1. Connect your iPhone to your Mac with a cable. -2. Open `ios/App.xcodeproj` in Xcode. +2. Open the project in Xcode with `npx inertia-native open ios`. 3. Select the **App** target, open **Signing & Capabilities**, and choose your **Team**. The project ships without a team, so Xcode can't sign the app until you pick one. If Xcode says the bundle ID isn't available, change it From e4ed9d00ed31b2d951ae688c1c2882d1d4449470 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Thu, 8 Oct 2026 23:59:23 +0300 Subject: [PATCH 12/17] docs: add starter kit recipes to feel native --- docs/guide/feel-native.md | 231 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 219 insertions(+), 12 deletions(-) diff --git a/docs/guide/feel-native.md b/docs/guide/feel-native.md index 90635e9..48916c2 100644 --- a/docs/guide/feel-native.md +++ b/docs/guide/feel-native.md @@ -2,8 +2,12 @@ After the [quick start](/guide/quick-start), your app runs in a native shell but still looks like a website in places. This page covers the changes that -make the biggest difference: hiding web-only parts, closing a modal after a -form, and adding a native button. +make the biggest difference: hiding web-only parts, closing the sidebar, +starting fresh after logging in, closing a modal after a form, and adding a +native button. + +The examples use the Laravel and Rails React starter kits, and the tabs show +where the two differ. ## Tell the app apart from a browser @@ -45,16 +49,59 @@ end ::: -In JavaScript that runs outside a component, read the user agent directly: +In JavaScript that runs outside a component, read the user agent directly. +Put the check in a module, so you can import it wherever you need it: + +::: code-group + +```ts [Laravel] +// resources/js/lib/native.ts +export const isNativeApp = + typeof navigator !== 'undefined' && + navigator.userAgent.includes('Hotwire Native'); +``` -```js -const isNativeApp = - typeof navigator !== 'undefined' && navigator.userAgent.includes('Hotwire Native') +```ts [Rails] +// app/javascript/lib/native.ts +export const isNativeApp = + typeof navigator !== "undefined" && + navigator.userAgent.includes("Hotwire Native") ``` +::: + The `typeof` check keeps the line safe during server-side rendering, where `navigator` may not exist. +For CSS, add a class to the `<html>` element. Do it in an inline script in +your root template's `<head>`, so the class is there before the page first +appears: + +::: code-group + +```blade [Laravel] +{{-- resources/views/app.blade.php, inside <head> --}} +<script> + if (navigator.userAgent.includes('Hotwire Native')) { + document.documentElement.classList.add('native-app'); + } +</script> +``` + +```erb [Rails] +<%# app/views/layouts/application.html.erb, inside <head> %> +<script> + if (navigator.userAgent.includes("Hotwire Native")) { + document.documentElement.classList.add("native-app") + } +</script> +``` + +::: + +A selector that starts with `.native-app` now matches only in the app. React +doesn't render `<html>`, so the class is safe with server-side rendering too. + ::: warning Don't check `window.webkit` `window.webkit.messageHandlers` comes from WKWebView, the web view on iOS. Android's web view doesn't have it, so a check like @@ -124,23 +171,183 @@ while the page loads. ### Page titles The native navigation bar shows the page's `<title>`, the same one you set with -Inertia's `<Head>`. If your `createInertiaApp` adds your app's name to every -title, the bar shows `Posts - Acme Shop` on every screen. Drop the suffix in -the app: +Inertia's `<Head>`. The starter kits add two things around it. Their +`createInertiaApp` adds the app's name to every title, so the bar reads +`Dashboard - Laravel`. And the header at the top of the page shows the title +again as breadcrumbs. + +Drop the app's name in the app: + +::: code-group + +```tsx [Laravel] +// resources/js/app.tsx +import { isNativeApp } from '@/lib/native'; -```js createInertiaApp({ - title: (title) => (isNativeApp ? title : `${title} - Acme Shop`), + title: (title) => + title ? (isNativeApp ? title : `${title} - ${appName}`) : appName, + // … +}); +``` + +```tsx [Rails] +// app/javascript/entrypoints/inertia.tsx +import { isNativeApp } from "@/lib/native" + +createInertiaApp({ + title: (title) => + title ? (isNativeApp ? title : `${title} - ${appName}`) : appName, // … }) ``` +::: + +Then hide the breadcrumbs with the `native-app` class. The kits build them +with shadcn/ui's `Breadcrumb`, which marks itself with +`data-slot="breadcrumb"`: + +::: code-group + +```css [Laravel] +/* resources/css/app.css */ +.native-app [data-slot='breadcrumb'] { + display: none; +} +``` + +```css [Rails] +/* app/javascript/entrypoints/application.css */ +.native-app [data-slot="breadcrumb"] { + display: none; +} +``` + +::: + +The header keeps its sidebar button, which is how you reach the menu in the +app. + +## Close the sidebar when a link opens + +On a phone, the starter kits show the sidebar as a sheet over the page. In the +Laravel kit, the sheet stays open when you tap a link in it: the next screen +opens with the sheet still on top, and it's still open when you go back. + +The shell shows each screen in the same web view, and the Laravel kit keeps +its sidebar layout between pages, so the open sheet comes along. Close it as +soon as a visit starts: + +```tsx +// resources/js/components/app-sidebar.tsx +import { router } from '@inertiajs/react'; +import { useEffect } from 'react'; +import { useSidebar } from '@/components/ui/sidebar'; +import { isNativeApp } from '@/lib/native'; + +export function AppSidebar() { + const { setOpenMobile } = useSidebar(); + + useEffect(() => { + if (!isNativeApp) { + return; + } + + return router.on('before', (event) => { + if (!event.detail.visit.async) { + setOpenMobile(false); + } + }); + }, [setOpenMobile]); + + // … +} +``` + +Inertia fires `before` when you tap a link, before the shell opens the next +screen. Polls and partial reloads are `async` visits that update the current +page, so they leave the sheet alone. In a browser the sheet behaves as before. + +The Rails kit renders its layout inside each page, so its sheet already closes +on the next screen. + +## Start fresh after logging in + +After you log in, the dashboard shows up on the login screen, and Back takes +you to the screens from before you logged in. Two things cause this. By +default, a form's result stays on the form's screen, so the shell doesn't +learn that you're on the dashboard now. And when it does, it opens the +dashboard on top of the login screen. + +First, have `inertia-native` hand the page a form lands on to the shell, as a +new visit. [Form redirects](/guide/navigation#form-redirects) explains the +option: + +::: code-group + +```tsx [Laravel] +// resources/js/app.tsx +initInertiaNative({ proposeFormRedirects: true }); +``` + +```tsx [Rails] +// app/javascript/entrypoints/inertia.tsx +initInertiaNative({ proposeFormRedirects: true }) +``` + +::: + +Then make the dashboard the first screen, with nothing to go back to. That's +the `replace_root` presentation. In `ios/App/path-configuration.json` and +`android/app/src/main/assets/json/path-configuration.json`, replace the +`^/$` rule with this one: + +::: code-group + +```json [Laravel] +{ + "patterns": ["^/$", "^/dashboard$"], + "properties": { + "presentation": "replace_root" + } +} +``` + +```json [Rails] +{ + "patterns": ["^/$", "^/dashboard$", "^/sign_in$"], + "properties": { + "presentation": "replace_root" + } +} +``` + +::: + +The home page `/` moves to the new rule too. Its old presentation, +`clear_all`, goes back to the first screen, and once you've logged in, that's +the dashboard. + +The rule also handles logging out. The Laravel kit logs you out to `/`. The +Rails kit logs you out to `/sign_in`, so its rule lists that page as well. As +a side effect, the **Log in** link on the Rails kit's welcome page also opens +the login screen as the first screen, without a Back button. + +The app reads these files when it's built, so run `npm run ios` and +`npm run android` again. If you also +[serve the path configuration](/guide/how-it-works#where-it-lives) from your +app, change the rule there too, because the server's copy replaces the bundled +file. + ## Close a modal after a form In the shells from `init`, a page whose URL ends in `/new` or `/edit` opens as a modal. After the form saves, you want the modal to close and show the screen underneath. A redirect can't do this: Inertia follows a redirect inside its own -request, so the shell never sees it. +request, so by default the shell never sees it. With `proposeFormRedirects`, +the shell sees the page the form lands on, but it opens that page as a new +visit instead of going back to the screen underneath. Instead, answer the form with a historical location. Hotwire Native reserves three paths for this, and the shell handles them itself. Your server doesn't From 48ab62451ca6b05afd4c939adca3c2182e59fd01 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Fri, 9 Oct 2026 11:50:10 +0300 Subject: [PATCH 13/17] docs: drop notes-to-self comments --- docs/guide/feel-native.md | 2 -- docs/guide/quick-start.md | 4 ---- docs/native/android.md | 2 -- docs/native/ios.md | 2 -- 4 files changed, 10 deletions(-) diff --git a/docs/guide/feel-native.md b/docs/guide/feel-native.md index 48916c2..a6d53c2 100644 --- a/docs/guide/feel-native.md +++ b/docs/guide/feel-native.md @@ -405,8 +405,6 @@ new post, answer with `/refresh_historical_location` instead. [Historical locations](/guide/navigation#historical-locations) lists all three paths and has a ready-made Rails helper. -<!-- TODO: confirm on Android that recede from a modal pops only the modal when the screen under it isn't the first one. NavigatorRule routes the modal result's POP again in the main stack (hotwire-native-android 1.3.1, NavigatorRule.kt newPresentation). --> - ## Add a native button A native button sits in the navigation bar, next to the title, where native diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index 1e23b15..df867a5 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -20,8 +20,6 @@ You need an Inertia app and the tools for the platforms you want to build: You can skip the tools for a platform you don't build. -<!-- TODO: confirm the minimum Android Studio version for AGP 9.2.1 --> - ## 1. Run `init` Run this in your app's root, the directory with `package.json`: @@ -30,8 +28,6 @@ Run this in your app's root, the directory with `package.json`: npx inertia-native init ``` -<!-- TODO: confirm the first inertia-native version that ships `init` and `run` --> - It asks a few questions. Press Enter to accept each default: - **Platforms**: iOS, Android, or both. diff --git a/docs/native/android.md b/docs/native/android.md index d904331..fea2da5 100644 --- a/docs/native/android.md +++ b/docs/native/android.md @@ -14,8 +14,6 @@ You need Android Studio to build and run the app: - The app runs on Android 9 (API level 28) and later. - Hotwire Native Android 1.3.1. Gradle downloads it on the first build. -<!-- TODO: confirm the minimum Android Studio version for AGP 9.2.1 / Gradle 9.4.1 --> - ## What `init` creates The project is plain Kotlin with no generated code, so you can read and change diff --git a/docs/native/ios.md b/docs/native/ios.md index 2657181..5867a78 100644 --- a/docs/native/ios.md +++ b/docs/native/ios.md @@ -124,8 +124,6 @@ bin/rails server -b 0.0.0.0 ::: -<!-- TODO: document a Vite dev server setup that keeps hot reload on a real device (server.host + server.hmr.host, Vite CORS) after testing it --> -<!-- TODO: confirm whether iOS asks for Local Network permission when the app loads a LAN dev server --> ## Change the name, bundle ID, or URL From d6dad56b88b6a0d344b6c8c2e784397df440bb9f Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Fri, 9 Oct 2026 11:50:18 +0300 Subject: [PATCH 14/17] docs: describe SSR as it works in 1.0 --- docs/guide/faq.md | 16 +++++----------- docs/guide/installation.md | 5 ++--- docs/native/ios.md | 1 - 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/docs/guide/faq.md b/docs/guide/faq.md index c39d6f9..68c2e21 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -79,22 +79,16 @@ native features where your users benefit from them. ## Does server-side rendering work? -Yes, for navigation. From version 1.1, `initInertiaNative()` does nothing when -there's no `window`, so an entrypoint that also runs on the server works -unchanged. -<!-- TODO: confirm version --> - -In version 1.0, wrap the call yourself: +Yes, with a guard. `initInertiaNative()` uses `window`, so if your entrypoint +also runs on the server, call it only in the browser: ```js if (typeof window !== 'undefined') initInertiaNative() ``` -`useBridgeComponent` reads `window` while a component renders, in all three -framework bindings. A page that uses a bridge component fails to render on the -server. - -<!-- TODO: update once useBridgeComponent is SSR-safe (react.js useState initializer, vue.js ref(), svelte.js readable() all read window during render) --> +Bridge components don't render on the server yet. `useBridgeComponent` reads +`window` while a component renders, in all three framework bindings, so a page +that uses a bridge component fails to render on the server. ## Which Inertia versions does it support? diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 1daf99e..2e710dd 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -48,9 +48,8 @@ See the [API](/reference/api) for every option. ### Server-side rendering -From version 1.1, `initInertiaNative()` does nothing on the server, where -there's no `window`. In version 1.0, guard the call: -<!-- TODO: confirm version --> +`initInertiaNative()` uses `window`. If your entrypoint also runs on the +server, guard the call: ```js if (typeof window !== 'undefined') initInertiaNative() diff --git a/docs/native/ios.md b/docs/native/ios.md index 5867a78..9f75e41 100644 --- a/docs/native/ios.md +++ b/docs/native/ios.md @@ -124,7 +124,6 @@ bin/rails server -b 0.0.0.0 ::: - ## Change the name, bundle ID, or URL `init` wrote your answers into a few places. Change them there: From 8688f8f3e4d880758ac8ad24147ac83c776179cd Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Fri, 9 Oct 2026 11:50:27 +0300 Subject: [PATCH 15/17] docs: explain Turbo and the simulator default --- docs/guide/introduction.md | 6 +++--- docs/guide/quick-start.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 8ac6201..bacc29d 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -22,9 +22,9 @@ Hotwire project. ## How it works -Hotwire Native was built for Turbo, and it expects to find Turbo on every page. -This package stands in for Turbo and translates its messages to Inertia's -router. When Inertia visits a new page, the native app opens a new screen for +Hotwire Native was built for Turbo, the page-navigation library that ships +with Rails, and it expects to find Turbo on every page. This package stands in +for Turbo and translates its messages to Inertia's router. When Inertia visits a new page, the native app opens a new screen for it. The package also lets your pages use bridge components, which draw native UI such as buttons in the navigation bar. diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index df867a5..c31b8f1 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -168,8 +168,8 @@ so `localhost` on the device reaches your computer. The first build downloads Hotwire Native and the other dependencies, so it takes longer than the ones after it. -If nothing is running, `npm run ios` asks which iPhone simulator to start and -selects the newest one. `npm run android` starts your emulator, or asks which +If nothing is running, `npm run ios` asks which iPhone simulator to start, +with the newest one as the default. `npm run android` starts your emulator, or asks which one when you have several. If more than one simulator or device is running, it asks which one to use. To see them all, add `--list`: From 3bc5d585d419a9239698a5fdc37caee09f02b8ab Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Fri, 9 Oct 2026 11:50:32 +0300 Subject: [PATCH 16/17] docs: link the home page to the quick start --- docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index fdd3cc7..1e36bf7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,8 +10,8 @@ hero: text: Get started link: /guide/introduction - theme: alt - text: Installation - link: /guide/installation + text: Quick start + link: /guide/quick-start - theme: alt text: GitHub link: https://github.com/inertia-native/inertia-native From 212ec4230632e811f9e377f99f35f25a44e5f038 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov <me@skryukov.dev> Date: Fri, 9 Oct 2026 11:50:32 +0300 Subject: [PATCH 17/17] docs: title the setup page Manual installation --- docs/guide/faq.md | 2 +- docs/guide/installation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 68c2e21..08a21a9 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -94,7 +94,7 @@ that uses a bridge component fails to render on the server. Inertia Native needs Inertia 3 (`@inertiajs/core` 3.0 or later) with React, Vue, or Svelte. Version 1.0 dropped support for Inertia 2. See -[Installation](/guide/installation#requirements) for the full list. +[Manual installation](/guide/installation#requirements) for the full list. ## Does it work offline? Does it support push notifications? diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 2e710dd..ed12f29 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -1,4 +1,4 @@ -# Installation +# Manual installation Most apps should start with `npx inertia-native init`, which installs the package, sets it up, and creates the native apps. The