Disclamer: Tutorial was made based on Ionic 6. In case of issues, please refer to Johan's comment: https://dev.to/maangs/comment/26ji3.
Are you tired of sending your app to Apple or Google to review it every time you update it?
Here I come with the possible solution. Solution because of which you will be able to bypass these reviews 90% of the time or even more.
As you maybe know, we have 4 ways to build mobile apps:
- Native - one app for iOS and one for Android,
- Compiled - one codebase compiled to 2 native apps written in React Native or Flutter for instance,
- Hybrid - web app opened in a web view inside of a mobile app - we build web app and then use Capacitor for example to run this web app inside of a native mobile app,
- PWA - basically a web app, hosted on the web but behaving like a mobile app (handling offline mode etc.).
And in this text, I want to show how we can use the third approach to make a mobile app which requires app’s review only once while being uploaded to App Store and Google Play and then it doesn’t need it anymore while being updated.
When we update such an app, we can just deploy new code to any server, cloud, static-site hosting provider or any other place which fits for web applications, instead of:
- building the whole app,
- uploading it to App Store and Google Play,
- and then waiting for an approval for the new version.
Let’s see how it can be done!
Let’s start with a bit of theory
I mentioned that in case of hybrid mobile apps, we have Capacitor at hand.
To be honest, we have not only Capacitor but also Cordova which Capacitor is based on but because Capacitor is more popular, has better community, deals with some problems better, and works beautifully with Ionic Framework I will tell more in a second, I simply recommend Capacitor.
And BTW. People doooon’t like Cordova as The State of JS 2022 or StackOverflow Survey for the same year suggest so recommending Capacitor instead of Cordova looks reasonable:
Capacitor is a runtime environment which we can use to open a web app inside of a mobile native app.
Additionally it provides plugins we can use to handle native functionality such as:
- geolocation,
- sharing,
- notifications,
- accelerometer,
- and many others.
There is a good sketch that illustrates how Capacitor works I’ve got from the article: “CapacitorJS: Turn Your Web App into a Mobile App”:
What does it mean to us?
It means that to make a mobile app using Capacitor, we don’t need anything more than knowledge on how to create a web app!
Dealing with native functionality comes down to using simple JavaScript API. For example:
import { Geolocation } from '@capacitor/geolocation';
const printCurrentPosition = async () => {
const coordinates = await Geolocation.getCurrentPosition();
console.log('Current position:', coordinates);
};
Additionally, as I mentioned, we can help ourselves with Ionic - framework which can be based on React, Vue, or Angular and which provides routing, theming, styles, and most importantly - a lot of built in components made for mobile like:
Alerts:
Toggles:
And many, many others:
All of them with dedicated styles for iOS and Android.
Getting back to the main topic - if our app is “just a web app”, it can use micro frontends in the same way as in case of web apps!
So we can create 2 separate micro frontends (MFs):
- first MF that consumes second MF and does nothing else,
- and the second MF that does everything else.
The first MF will be then built by us and uploaded to App Store and Play Store.
The second MF will be placed under some URL like https://our-awesome-micro-frontend.com
and retrieved by the first MF in runtime (every time the user runs our app).
In this scenario, to update our mobile app, we don’t need to build it and upload to App Store and Play Store every time but just update the second, remote MF and deploy it to the server. First MF will retrieve the newest version of the second one in runtime.
But, is it okay for Apple and Google to basically omit their review processes?
I’ve asked myself the same question and the answer is - they are okay with it.
Micro frontends implemented in this way are treated as any other content retrieved from an external API.
Enough theory! Let’s check it by creating our base app and micro frontend it will consume
For code with everything I will show today, you can check my
[ionic-module-federation
GitHub repository](https://github.com/robert-orlinski/ionic-module-federation)!
I will scaffold both apps using Ionic which by default uses Capacitor (both mentioned before).
I will select React as a used framework but you can select Angular or Vue instead.
If I choose React as a used framework, Ionic’s boilerplate is based on Create React App. Because of it I want a tool which edits Webpack’s configuration without ejecting CRA’s configuration.
Why?
Everything because I will handle our micro frontends using Webpack’s module federation.
If you are not familiar with module federation, you can check these videos on YouTube:
Tool I will use in order to change our Webpack’s configuration is CRACO.
Let’s go 🎉
We can create a new catalogue (let’s call it ionic-module-federation
):
mkdir ionic-module-federation
cd ionic-module-federation
Inside of it we create 2 new projects:
- 1 for our base app that will be uploaded to App Store and Google Play,
- and 1 for micro frontend that will be consumed by the base app.
To crate them, we can install @ionic/cli and then run ionic start
for both apps:
npm install -g @ionic/cli
ionic start
Then we get through a wizard for the base app:
? Use the app creation wizard? No
? Framework: React
? Project name: host
? Starter template: blank
? Create free Ionic account? No
Going through the options I’ve chosen:
- I don’t want to use app creation wizard. If I did, I would be redirected to Ionic’s website, need to create an account, and my app would be automatically added to Appflow’s account. I don’t need these.
- I choose React as my JS framework.
-
host
is the name of my base app. - I don’t want any starter template for my base app so I choose
blank
. - And I don’t want to create Ionic’s account as I mentioned in the first point.
And then do the same for the micro frontend consumed by our base app:
? Use the app creation wizard? No
? Framework: React
? Project name: remote
? Starter template: list
? Create free Ionic account? No
The only differences here are:
- The name (of course).
- The fact that I am using starter template. Probably it’s not required in your case but to nicely show how our base app consumes the
remote
app I will use it.
Then we land with our 2 front-end projects
File structure looks like this:
Both of them are based on Create React App.
Of course, right now we can develop our apps in a way Ionic and Capacitor let us to develop them:
- create web apps,
- handle native functionality in places we need to by using Capacitor’s plugins,
- build our apps for iOS and Android,
- test them using Xcode and Android Studio,
- upload them to App Store and Google Play.
But these actions are not the ones I will take right now. You can read more on them in Ionic, Capacitor, Apple, and Google documentations.
Right now, I want to do micro frontend stuff! 🥳
So let’s configure module federation for both apps!
As I mentioned, in our case, the perfect tool for this job is CRACO. It will let us simply overwrite CRA’s configuration without ejecting.
To start, we can install it for both apps:
cd host
npm i -D @craco/craco
cd remote
npm i -D @craco/craco
You can use npm workspaces or yarn workspaces to install it once.
And then create a file in which our configuration will be placed. File has to be called craco.config.js
.
We will create it for both apps - host
and remote
.
For host
our craco.config.js
will have such a content:
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const deps = require('./package.json').dependencies;
module.exports = {
webpack: {
configure: {
output: {
publicPath: 'auto',
},
},
plugins: {
add: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
remote: 'remote@http://localhost:3002/remoteEntry.js',
},
exposes: {},
filename: 'remoteEntry.js',
shared: {
...deps,
react: {
singleton: true,
eager: true,
requiredVersion: deps['react'],
},
'react-dom': {
singleton: true,
eager: true,
requiredVersion: deps['react-dom'],
},
},
}),
],
},
},
};
Where http://localhost:3002
is the address of our remote
app.
In case you upload remote
app to some remote server, you will need to replace it by server’s url.
FYI - to always open
remote
app on port3002
instead of default3000
I changestart
script inside ofremote
'spackage.json
fromreact-scripts start
toPORT=3002 react-scripts
.
In turn, for remote
app craco.config.js
will have this content:
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const deps = require('./package.json').dependencies;
module.exports = {
webpack: {
configure: {
output: {
publicPath: 'auto',
},
},
plugins: {
add: [
new ModuleFederationPlugin({
name: 'remote',
remotes: {},
exposes: {
'./App': './src/App',
},
filename: 'remoteEntry.js',
shared: {
...deps,
react: {
singleton: true,
eager: true,
requiredVersion: deps['react'],
},
'react-dom': {
singleton: true,
eager: true,
requiredVersion: deps['react-dom'],
},
},
}),
],
},
},
};
Very similar. The only difference is the fact that instead of setting remotes in module federation configuration, we expose specific components.
Then we can use our exposed component
In my case, I expose only one component. This is the main one (App
) because I want to consume whole remote
app basically.
Then to consume this component, we can simply import it inside of the host
app in this way:
import App from 'remote/App';
I will do it inside of the host/index.tsx
file.
BTW. Regarding
.tsx
files - Ionic supports TypeScript by default and there is even no way to generate boilerplate with files written in classic JS.
Ionic suggests that if for some reason you don’t want to use TS, you can simply rename all files and remove type annotations from them.
Probably you’ve already spotted that TypeScript gives an error when we import our App
:
That’s because module federation doesn’t contain any types.
I can silence TypeScript for now by for example creating file called remote.d.ts
inside of our src
directory and put code like this inside:
declare module 'remote/App';
but of course, this is not a good solution.
You can find better solution in the 5th part of Five Module Federation/Micro-Frontend Mistakes video I’ve mentioned several paragraphs before.
Now we can run our app!
But one more thing.
To use CRACO, we need to change start
, build
, and test
commands in package.json
files for both host
and remote
:
// host:
"scripts": {
"start": "PORT=3002 craco start",
"build": "craco build",
"test": "craco test --transformIgnorePatterns 'node_modules/(?!(@ionic/react|@ionic/react-router|@ionic/core|@stencil/core|ionicons)/)'",
"eject": "react-scripts eject"
},
// remote:
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test --transformIgnorePatterns 'node_modules/(?!(@ionic/react|@ionic/react-router|@ionic/core|@stencil/core|ionicons)/)'",
"eject": "react-scripts eject"
},
Then we can run both apps 🎉
Let's use npm start
twice - inside of a host
and remote
app separately:
cd host
npm start
cd remote
npm start
Aaaaand let’s get this error in runtime:
Ouch 🤧
Fortunately, there is nothing to worry about.
We only need to create new file (let’s call it src/bootstrap.tsx
as it was adapted), copy src/index.tsx
content into it and change already copied src/index.tsx
content to:
import('./bootstrap');
export {};
You can read more on the topic in “Troubleshooting” section in this article: Module Federation. Advanced API in Webpack 5.0.0-beta.17
When we restart our apps now.
We should see the same content under both [localhost:3000](http://localhost:3000/)
(host
) and [localhost:3002](http://localhost:3002/)
(remote
):
That means, our module federation works! 🥳
We can change anything inside of remote/App
component and see that the change is visible both on [localhost:3000](http://localhost:3000/)
and [localhost:3002](http://localhost:3002/)
.
And that also means we will be able to skip mobile app’s review after we upload our app to App Store and Google Play!
We don’t have to edit anything inside of our host
micro frontend (one which will be then uploaded to App Store and Google Play) to update our mobile app.
We can always update only remote
micro frontend (the same one we can host “somewhere on the internet”) and just retrieve it in runtime.
With one note - we can do it as long as we don’t need to add any native functionality to our mobile app
If we want to handle stuff like geolocation, notifications, or accelerometer in remote
, we have to install proper Capacitor plugin both in host
and remote
and then sync native apps.
We can do it to show how it works.
Let’s test sharing native functionality.
First of all, we can add some code in our remote
app that will handle content sharing.
I will add very simple button
to the end of the Home
component, that will invoke sharing on click:
// ...
<IonList>
{messages.map((m) => (
<MessageListItem key={m.id} message={m} />
))}
</IonList>
</IonContent>
<IonButton
onClick={async () => {
await Share.share({
title: 'See cool stuff',
text: 'Really awesome thing you need to see right meow',
url: 'http://ionicframework.com/',
dialogTitle: 'Share with buddies',
});
}}
>
Share
</IonButton>
</IonPage>
// ...
Share
is imported from the @capacitor/share
plugin which handles sharing functionality:
import { Share } from '@capacitor/share';
Of course, our @capacitor/share
plugin has to be installed:
# ...in `host` app to take it into consideration while updating native platforms:
cd host
npm install @capacitor/share
# ...and in `remote` app to import it properly and without errors:
cd remote
npm install @capacitor/share
Then I can build our host
app and generate it for native platforms.
For testing purposes, I will add only iOS platform to our Ionic project, then generate and open our app inside of XCode:
cd remote
npm start # If we want to run our `host` app after building it and consume our micro frontend, `remote` app has to operate.
cd host
ionic capacitor add ios
npm run build
ionic capacitor sync --no-build # `ionic capacitor sync` builds our app using `react-scripts` so because I use CRACO, I don't use this default build to happen. I build our app using `npm run build` before running `ionic capacitor sync`.
ionic capacitor open ios
After running ionic capacitor open ios
, native app gets opened inside of XCode. When we run it, we see our button at the bottom of the home view:
Then when we click on it, we can see our sharing menu:
It wouldn’t work if we added @capacitor/share
package only to the remote
app. We had to add it also to our main app - that is the host
app.
And that would be it!
Once more, you can check
[ionic-module-federation
repo](https://github.com/robert-orlinski/ionic-module-federation) for code created in this tutorial.
In the way I’ve shown, we can deploy new version of our mobile app anytime we want:
- Without asking Apple and Google for review.
- Without building and uploading our app to App Store and Google Play every time.
We can just have 1 app which consumes remotely hosted micro frontend. That micro frontend can be changed anytime we want (assuming that we don’t add any new native functionality).
I hope this tutorial was useful for you!
Top comments (5)
Great article Robert, informative and easy to follow!
I would like to point out some things that could be helpful to others:
"start": "set PORT=3002&& craco start",
Thank you Johan for these words and especially - for listing these 3 points for others!
Hi Robert,
Great article thanks for sharing.
Relying on module federation this way would indeed bypass the need to go through the store review process but then your JS/CSS assets are being fetched remotely which will impact the app performance because it has to wait for the assets to be downloaded.
Am I correct ?
Hello! đź‘‹
Thank you for these words! I appreciate it.
But you are right and this is a good point - by default all resoruces are downloaded during the app’a opening.
You can try to overcome it by using some hybrid approach in which you update the app itself by sending it to Apple/Google and use microfrontends conditionally (if there is newer microfrontend you use it, if already approved app’s version is the latest, you use it instead).
Additionally, I am not sure how does the webview used under the hood by Capacitor handles “browser” cache. Maybe this is something we can rely on.
This is for sure a tradeoff we have to have in our minds. Thank you for rising it 🙏
I got Error "Uncaught (in promise) Error: Cannot find module 'remote/App'"