This project is an example of how to build a hybrid native + web-based mobile application.
It uses a combination of platform-native code and web views to display, and communicate with, pages from a Next.js web app.
Web app running in the browser (note header and footers visible) Web app running in an embedded native web view on iOS (note header and footer are hidden).The hybrid architecture is useful for rapid development of mobile apps that can use as much (or as little) of an existing web frontend as required.
The web app and native code communicate through the use of the Channel Messaging API. This allows for marshalling data between native code and the browser's runtime.
This is useful for sharing state, such as authentication or session tokens, across the two contexts.
For more details of the capabilities and mechanisms see the technical information section below.
The project consists of two parts:
- A Next.js web app (./web)
- A native, Swift-based iOS app (./mobile-ios)
- Node + pnpm
- Xcode 16
$ cd web
$ pnpm run dev
Web app will be avilable at: localhost:3000
- Open
./mobile-ios/MobileApp.xcodeproj
in Xcode, then - run the
MobileApp
scheme on an iOS simulator.
The web app uses the presence of pre-defined user agent string nativeMobileAppUserAgent
to detect if it is running in a native mobile web view context or not.
In our example, this has the value mobile/app
. For details of how this is added to the user agent on iOS see the WKWebKit wrapper view.
Back on the web side of things, the custom react hook useAppInNativeMobileContext
can then be used from any component to render or behave conditionally depending on whether the web app is running in a web or native mobile web view.
Note: Any React component using this hook will need to declare 'use client'
at the top of the file to indicate that this logic can only run client-side.
When the Next.js router is in use, iOS web view navigation delegate methods are never invoked in native code.
To bypass this, the Next.js Link
component is wrapped, see: WrappedLink
.
The wrapped link uses standard links when in a native context, and the usual Next.js Link
components when in a normal web context.
This component should be used for all links in the web app that may be visible to the native web view.
With link navigation interception working on the native side, navigation can then be routed to a native view if one exists for a given link.
React components can be hidden when rendering in a native mobile context, which is useful for hiding things like headers and footers (as these will be implemented in the native app), and preventing unwanted navigation to other parts of the main web app.
An example of doing this can be found in the Header component in this project.
Note: This implementation does result in a brief flash of the web header when first rendering, which is down to the way Next.js renders pages. This is considered an acceptable tradeoff.
The web app uses the Channel Messaging API to send RPC-like messages to the native host app.
An example request that is sent from the web app, and expects a response, is shown below:
{
"method": "infoRequest",
"params": {
"text": "Some data"
}
}
On the web side useMessageHandler.ts
is used to:
-
Create a
MessagePort
object, which will be used for sending messages to the native context. -
For non-Safari (i.e. Android) web views, the React hook
useMessageEventListener
is used to register a handler method for received messages. -
This is not required for web views on iOS, as a callback is passed directly in the post message method call (as illustrated in the following from the
sendPostMessage
implementation):const response = await window.webkit.messageHandlers?.messageHandler?.postMessage(message) return callback?.(JSON.parse(response).result)
-
A post request can be sent using the
sendPostMessage
method:if (canSendPostMessage()) { await sendPostMessage('infoRequest', {'text': 'Some data'}, updateMessage) }
On the native side:
-
On iOS, the WebKit
WKScriptMessageHandlerWithReply
delegateWebScriptMessageHandler
:- Decodes the message, and
- Issues the appropriate response to the callback, e.g.
let response = WebScriptResponse(method: "infoResponse", result: ["message": message]) let responseString = String(data: try! JSONEncoder().encode(response), encoding: .utf8) replyHandler(responseString, nil)
-
Back on the web side again, this response is parsed in the callback/listener method and dealt with as needed.
An example illustrating the message passing behaviour is included in the project, and is shown below.
Web app running in aWKWebView
on iOS, making a request, and receiving a response from native code.
-
When the user taps the 'Request Native Response' button, the web app sends an
infoRequest
message, with an attached payloadSome data
. -
The Swift code then replies with a message that contains the originally passed-in payload.
-
The web app receives this message and updates the original page to display the received response.
This project is distributed under the terms of both the MIT license and the Apache License (Version 2.0).
See LICENSE-APACHE and LICENSE-MIT, and for details.