Full guide on using CapacitorJS to build a native iOS app

Full guide on using CapacitorJS to build a native iOS app

Inspired by @disistemas’s recent post, I decided to check out CapacitorJS. It feels to me like the perfect fit for Redwood in that you only need to build your frontend once, unlike RN and similar solutions.

Because Capacitor is designed to be included in a project directly, I don’t feel it makes sense to add a new side for it - especially because if you want to use any of the plugins, you’ll need to install those to the web side anyway.

These instructions are partially iOS specific - if you want to build for Android, you can still use these setup instructions.

I also use TailwindCSS, but you can generally do the same with any other way of doing CSS.

Getting started

First, install the requisite packages to your web side:
yarn workspace web add @capacitor/core && yarn workspace web add @capacitor/cli -D

Next, so we can easily run the Capacitor CLI commands from the root directory, add the following to your root package.json:

// package.json
"scripts": {
  "cap": "yarn workspace web run -- cap"

Then, create the capacitor config file. Run yarn cap init, which will ask you some questions and then create a capacitor.config.ts file in the root of your web side. Because there’s no way to tell Capacitor to look anywhere else, you can’t move it, for example, into web/config - you’ll need to leave it there.

Mine looks like this:

// web/capacitor.config.ts
import { CapacitorConfig } from '@capacitor/cli'

const config: CapacitorConfig = {
  appId: 'app.spoonjoy',
  appName: 'spoonjoy',
  webDir: 'dist', // note that this is where RedwoodJS will build your project - the default Capacitor path is 'www'
  bundledWebRuntime: false,

export default config

To prepare for building our native app(s), I would also recommend modifying the config to output your native apps outside of your web workspace. For example, for iOS, add this to capacitor.config.ts:

ios: {
  path: '../ios',

Then, install the platforms you want. I only wanted iOS, so I ran yarn workspace web add @capacitor/ios. (You can always add more later.)

Create the project for your native app: yarn cap add ios.

If this is your first time developing an iOS app, you’ll also need to install Xcode and cocoapods. Grab Xcode from the App Store, and run brew install cocoapods. (see here for Capacitor’s full environment setup instructions.)

If you run into any weird errors at the pod install stage, run sudo xcode-select --reset && rm -rf ios and then try yarn cap add ios again.

The next stage is to set up a CORS config to allow your app to access your API. This can get pretty involved, so I would refer to the Redwood docs on this: Cross-Origin Resource Sharing | RedwoodJS Docs

Next, to sync the web app to the native app, run: yarn rw build web && yarn cap sync.

Xcode time!

Now it’s time to set up your native app. Start by opening your project in Xcode by running yarn cap open ios. Xcode should open your newly created project - start her up by selecting a simulator and hitting the play button:

Your app should open in the emulator - it’s not perfect yet, but we’ll get to that in a bit:

This is also a good time to spin up your API server. Back in Redwoodland, run yarn rw dev --fwd="--allowed-hosts all".

Interlude: improving the development experience

We don’t want to run that build and copy command every time we make a change, right? Setting up Live Reload should fix this. See here: Live Reload | Capacitor Documentation

I found that Live Reload was difficult to use and not predictable, so instead I added another script to my package.json:

  • "cap:bso": "yarn rw build web && yarn cap sync && yarn cap open ios"

Additionally, you can open a debugger specific to the native app. (For example, for iOS, you can open the Safari dev tools.) See here: Debugging | Capacitor Documentation

Common Issues

At this point, you might run into some weird issues. I’ve begun to compile some common ones, and will continue to grow this list.

Our app overlaps with the status bar and home bar

Our app overlaps with the status bar and home bar

The issue here is that while normally we want our site to fill the browser, on iOS, there’s a notch and status bar on the top that gets in the way, and a home bar on the bottom! So what do we do? We add padding to the site!

Additionally, if you have any scrolling pages, even with padding, they’ll scroll right into the status bar! The easiest thing to do is to just place an opaque bar to cover that area. So, we’ll do that as well.

One last thing - we don’t want to forget about Toast notifications. Those want to go at the very top of the screen, so they’ll get totally covered up. We’ll want to add a margin to push that down.

Screenshot 2023-03-03 at 2.37.59 PM

But how do we know how large that space should be? It varies by device. Thankfully, we have the env(safe-area-inset-*) variables at our disposal (see here: env() - CSS: Cascading Style Sheets | MDN).

Per the MDN docs, first, add the following in the head tag of index.html:
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0">

You probably already have a viewport tag with content="width=device-width, initial-scale=1.0" as an attribute (Redwood generates this). We need to add viewport-fit=cover at the beginning of that, or this won’t work.

Then, in index.css, add the following classes:

.top-safe-cover {
  height: env(safe-area-inset-top);
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 9999;

.safe-area-padding {
  padding: env(safe-area-inset-top) env(safe-area-inset-right)
    env(safe-area-inset-bottom) env(safe-area-inset-left);

If you were using h-screen css classes anywhere, you’ll need to switch to using h-full on all the parent tags (so it bubbles down) instead. Otherwise, your h-screen component will now be taller than the screen because it’s pushed down by the padding.

Now, update index.html to fix the first two issues:

- <body class="h-full">
+ <body class="h-full safe-area-padding">
   <div id="redwood-app" class="h-full">
+    <div class="top-safe-cover bg-white" />
     <!-- Please keep the line below for prerender support. -->
     <%= prerenderPlaceholder %>

You can give the top-safe-cover <div /> whatever style matches your app - I just gave it an opaque white background.

Then, for the <Toaster /> component, I had trouble getting this to work with classes, so I just applied the style directly (max() usage is so that we still have some padding where safe-area-inset-top is 0):

+   containerStyle={{
+     top: 'max(1rem, env(safe-area-inset-top))',
+   }}
      className: 'rw-toast',
      duration: 6000,

Now, everything should look great as far as the header is concerned!

Images don't show up

Images don’t show up

I ran into a weird issue where my images weren’t showing up - the network debugger was showing status code 200, but it wasn’t actually getting any content.

In my case, I’m using Cloudinary, and has been doing automatic format selection. For whatever reason, iOS doesn’t like this, and seems to want specifically a JPG. Once I removed f_auto from my image URLs, it worked.

Images are blurry

Images are blurry

If you’re dynamically sizing images based on window.screen.width, you can’t. iOS devices use scaling factors, so you need to instead use window.screen.width * (window.devicePixelRatio || 1) to get the true pixel width.

Webauthn doesn't work

Webauthn doesn’t work


Adding your branding

UNDER CONSTRUCTION - but see GitHub - ionic-team/capacitor-assets: Local Capacitor icon/splash screen resource generation tool

Building the app for production

I haven’t done this yet myself, and will update this section once I do :slight_smile:

I expect that at minimum, we’ll need to point the app to use the production API url, and I don’t want to need to change configuration files between building for dev and building for prod. Stay tuned!

Let me know if anything was unclear or if you have any questions!


Thanks for the guide Ari!

I was wondering if you got any further with the webauth not working issue? I’ve managed to get our app running on ios and android, but stuck on the login screen. I haven’t done much mobile development myself, so may be missing something obvious.

1 Like

Sort of paused on this :slight_smile:

The issue with getting Webauthn to work is that right now, it only supports a single supported domain/origin. You can give it the custom scheme for your Capacitor app and it should work. But then it won’t work anywhere else. I haven’t taken a look at the source code yet to see if this is an easy fix, but @rob might know.

You’ll also need to change your cookie config to be SameSite: 'None', if you haven’t already done that (Cross-Origin Resource Sharing | RedwoodJS Docs). You might run into issues with that config in localhost, in which case what you need to do is set up your dev environment to have SSL enabled.

Let me know if you need help with that :slight_smile:

Also, at least for an iOS app (or web app, on any Apple device), you can get an even better flow than Webauthn by using Sign In with Apple - 🎉 Announcing dbAuth OAuth Plugin v1.0.0 - easily enable Sign in with Apple, GitHub, Google, and more!

Oh man it’s been a while since I was in the auth code. The issue is that multiple domains can’t use the Webauthn code?

In the underlying webauthn lib, it does say that expectedOrigin and expectedRPID can be an array of URLs: https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/authentication/verifyAuthenticationResponse.ts#L36-L37

And the Redwood code just forwards those settings on to that lib: https://github.com/redwoodjs/redwood/blob/main/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts#L744-L745

I translated “RPID” in the lib to just “domain” in our config file since it’s just a domain.

So maybe just try sending in arrays for both of those values?

1 Like

Ooo thank you!! Definitely worth a shot, and seems like it’ll be an easy fix if it doesn’t work :slight_smile:

Hey @arimendelow did you ever get this fully working?

Not yet :confused: It’s still on my list, but keeps getting pushed down as other items come up.

If you want to pick up from where I’ve left off, though, I’m happy to help out where needed!!

Have you played around with Capacitor at all yet?