Hotwire Native Integration

Go Native with your Symfony app

Wrap your Symfony web application in a native mobile shell with Hotwire Native. Detect native requests, conditionally render content, and generate JSON configurations for your mobile clients.

composer require symfony/ux-native
Read the docs

Without UX Native

A Symfony + Bootstrap todo-list app displayed inside a mobile browser, with the browser navigation bar and the web navbar visible.

Browser navigation bar, web navbar — it feels like a website.

With UX Native

The same Symfony + Bootstrap todo-list app but with Hotwire Native: native title bar, no browser chrome, clean native experience.

Native title bar, no browser chrome — it feels like an app.

One codebase, two experiences

No need to duplicate templates. The ux_is_native() Twig function lets you adapt your existing Twig templates for native clients. For example:

  • hide the navbar,
  • remove the footer,
  • or show mobile-specific content, all from the same code.
templates/base.html.twig
{# Navbar only visible in the web browser #}
{% if not ux_is_native() %}
    <nav class="navbar">
        <a href="/">TaskFlow</a>
    </nav>
{% endif %}

<h1>My Tasks</h1>
<!-- ... -->

JSON configurations, the Symfony way

Use PHP attributes AsNativeConfigurationProvider and AsNativeConfiguration to declare your native configurations: path rules, settings, per-platform overrides.

UX Native serves them dynamically during development and can dump them as static JSON files for production deployment.

src/Native/AppNativeConfiguration.php
namespace App\Native;

use Symfony\UX\Native\Attribute\AsNativeConfiguration;
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Configuration\Rule;

#[AsNativeConfigurationProvider]
final class AppNativeConfiguration
{
    #[AsNativeConfiguration('/config/ios_v1.json')]
    public function iosV1(): Configuration
    {
        return new Configuration(
            rules: [
                new Rule(
                    patterns: ['.*'],
                    properties: [
                        'context' => 'default',
                        'pull_to_refresh_enabled' => true,
                    ],
                ),
            ],
        );
    }
}

Bridge Components

Bridge components let you progressively enhance specific web elements with native UI (like replacing a web link with a native navigation bar button), and without changing your server-side code.

The TaskFlow app showing a native navigation bar button added via a bridge component, replacing the original web link.

Three layers, one component

A bridge component is made of three parts: an HTML element with data-controller, a Stimulus controller extending BridgeComponent, and a matching Swift class.

The web and native sides are linked by a shared component name: button in this example.

templates/tasks/index.html.twig
<a href="/profile"
   {{ stimulus_controller('button') }}
   data-bridge-title="Profile"
>
    View profile
</a>

Stimulus sends, native handles

The Stimulus controller calls this.send("connect", {/* ... */}, callback) to pass data to the native side.

When the native button is tapped, it replies and the callback fires: clicking the original web element.

assets/controllers/button_controller.js
import {BridgeComponent} from "@hotwired/hotwire-native-bridge"

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

    connect() {
        super.connect()

        const element = this.bridgeElement
        const title = element.bridgeAttribute("title")
        this.send("connect", {title}, () => {
            this.element.click()
        })
    }
}

Native code, same component name

The Swift component overrides name to match the Stimulus identifier button.

When a message arrives, it builds a UIBarButtonItem and places it in the navigation bar. Tapping it replies to the Stimulus controller, which clicks the original link.

Register it one in your AppDelegate.swift:

ios/AppDelegate.swift
Hotwire.registerBridgeComponents([
    ButtonComponent.self
])
ios/ButtonComponent.swift
import HotwireNative
import UIKit

final class ButtonComponent: BridgeComponent {
    override class var name: String { "button" }

    override func onReceive(message: Message) {
        guard let viewController else { return }
        addButton(via: message, to: viewController)
    }

    private func addButton(via message: Message,
                           to viewController: UIViewController) {
        guard let data: MessageData = message.data() else { return }

        let action = UIAction { [unowned self] _ in
            self.reply(to: "connect")
        }
        let item = UIBarButtonItem(title: data.title, primaryAction: action)
        viewController.navigationItem.rightBarButtonItem = item
    }
}

private extension ButtonComponent {
    struct MessageData: Decodable {
        let title: String
    }
}

Hide web elements in native apps

When a native button is displayed in the navigation bar, the original web link can be hidden.

This CSS selector targets elements marked as supported bridge components, preventing duplicate UI from appearing in the native app.

assets/styles/bridge.css
[data-bridge-components~="button"]
[data-controller~="button"] {
  display: none;
}

Install It

$ composer require symfony/ux-native