Starters

Mobile starter

Start an iOS and Android app on the Frigear mobile shell that signs in at frigear.nu or any OpenID Connect issuer, from the mobile starter.

What you'll have afterwards:

  • An iOS and Android app, also runnable in a browser on http://localhost:3001, that signs you in at an issuer you already have.
  • A Home tab that shows who signed in and calls /api/me on your API with the access token.
  • An Account tab that shows your profile and signs you out.

Use this starter when your users and API already live on a server: frigear.nu, your own OAuth Server, or any issuer on the checklist. It is a pnpm workspace with one app in apps/mobile. The app is a static Nuxt 4 single-page app (SPA) that Capacitor wraps for iOS and Android.

No server yet? The standalone mobile starter is this app plus a web app that is the issuer and the API, wired together.

Prerequisites

The tools in Requirements, plus:

ToolFor
XcodeiOS: a recent Xcode with an iOS simulator. The native project uses Swift Package Manager, no CocoaPods.
Android StudioAndroid: a Java Development Kit (JDK) and the Android SDK.

1. Create the project

Copy .starters/mobile with pnpm create frigear-app (CLI) or with giget. pnpm create frigear-app also runs pnpm install and pnpm prepare:env:

pnpm create frigear-app my-app --platform mobile
cd my-app
git init

pnpm prepare:env copies apps/mobile/.env.example to apps/mobile/.env. It never overwrites a value.

2. Choose the issuer

The app does not run until the issuer knows it. Register it there before the first run.

Set the issuer and your API in apps/mobile/.env:

VariableWhat it is
NUXT_PUBLIC_AUTHENTICATION_CLIENT_ISSUERThe issuer URL, for example https://frigear.nu. The example file sets http://localhost:3000.
NUXT_PUBLIC_AUTHENTICATION_CLIENT_API_BASEThe API that useApi() and $api call with the access token. Without it: the issuer.
NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_IDOptional. Overrides clientId of identity.ts per environment.
NUXT_PUBLIC_AUTHENTICATION_CLIENT_SCOPEOptional. Overrides scopes of identity.ts, space separated. Must include openid.
NUXT_PUBLIC_* values are baked into the build. Run pnpm mobile:sync after you change them.

Then register a public OAuth application at the issuer: one that uses PKCE (Proof Key for Code Exchange) and has no secret. Use the values from apps/mobile/identity.ts:

WhatValue in the starter
Client idmobile (clientId)
Redirect URI of the appcom.example.mobile:/auth/callback (appId in lowercase, then callbackPath)
Redirect URI for pnpm devhttp://localhost:3001/auth/callback (development only)
Cross-origin resource sharing (CORS) originscapacitor://localhost (iOS), https://localhost (Android), http://localhost:3001 (development)
Scopesopenid profile email (see below for offline_access)

The app needs a refresh token to stay signed in:

  • OAuth Server always issues one. It does not support the offline_access scope and drops it.
  • Most other issuers only issue one for offline_access. For Keycloak, Auth0, Zitadel and the like, add it to scopes in identity.ts, or to NUXT_PUBLIC_AUTHENTICATION_CLIENT_SCOPE.
Keep openid in the scopes. The signed-in user comes from the ID token.

How that looks for each kind of issuer:

Add one entry to oauthServer.clients with these redirect URIs, and cors.paths: ['/api/'] for your API. Add apps/mobile to an existing workspace shows the config. The standalone starter has it wired up.

3. Run it in a browser

pnpm dev

Open http://localhost:3001. Every page needs a signed-in user (the '/**' route rule), so you land on /login:

  1. Sign in sends you to the issuer, where you sign in and approve the app. Passkeys work when the issuer offers them, as the workspace and mobile-standalone starters do.
  2. The issuer redirects to http://localhost:3001/auth/callback. The app exchanges the code for tokens and opens Home.
  3. Account shows your name and email. Sign out revokes the refresh token at the issuer and returns to /. The route rule sends you on to /login.
What you should see:Home shows your sign-in (useUserSession().user) and the result of GET /api/me on your API. A reload keeps you signed in.

Tokens live in localStorage in a browser. On a device they live in the Keychain or Keystore, through @aparajita/capacitor-secure-storage. The access token is refreshed when it expires.

4. Run it on the iOS simulator

pnpm mobile:ios

The script does four things:

  1. The first time, it creates the native projects (frigear-mobile native --if-missing). That runs cap add ios and cap add android, and adds the URL scheme of the redirect URI to Info.plist and AndroidManifest.xml.
  2. It builds the SPA (nuxt generate).
  3. It copies the SPA into ios/ (cap sync ios).
  4. It asks which simulator to run (cap run ios).

Sign in opens the in-app browser at the issuer. After you approve, the issuer redirects to com.example.mobile:/auth/callback. iOS hands that URL to the app, and you are signed in. The simulator shares your Mac's network, so an issuer on http://localhost:3000 works as is.

Run pnpm mobile:sync after every change to the web code or .env, and after every pnpm install. The Swift Package Manager paths point into node_modules.

pnpm mobile:doctor lists missing plugins and URL schemes.

5. Run it on an iPhone

With an issuer on the internet, open the project in Xcode and run it on the phone:

pnpm mobile:ios:device

The script builds with MOBILE_LAN=1. That rewrites localhost in the issuer and API base to this Mac's local network (LAN) address. MOBILE_LAN=192.168.1.20 picks the address. So a phone on the same Wi-Fi reaches an issuer running on your Mac. Start that server on every interface; for a Nuxt server, run nuxt dev --host 0.0.0.0.

Then, in Xcode:

  1. Select the App target, then Signing & Capabilities, and pick your team. A free Apple ID works, but the install then expires after 7 days. Do not commit the team id.
  2. Connect the phone and turn on Developer Mode on it (Settings, Privacy & Security). Choose it as the run destination and press Run.
  3. The first time, trust the developer profile on the phone (Settings, General, VPN & Device Management). Allow local network access when asked.
MOBILE_LAN never applies to nuxt dev. The build fails when MOBILE_RELEASE=1 is also set, so a LAN address never ships. For live reload on a device, see Device development.

6. Run it on Android

pnpm mobile:android

The steps are the same as for iOS: native project, build, cap sync android, cap run android.

  • Android shows the web tab bar and header. The native chrome is iOS only by default.
  • The back button closes an overlay, then goes back, then returns to the first tab.
The Android WebView runs at https://localhost, so a plain http development server is mixed content. Use an https issuer, or live reload, when you test on Android.

How the guards work

Mobile protects nothing by default. The app picks its guards with Authorization, in apps/mobile/nuxt.config.ts:

apps/mobile/nuxt.config.ts
routeRules: {
  '/**': { authenticated: true },
},
authorization: { redirectTo: '/login' },
  • /login and the callback stay public without a rule.
  • Both tabs have requiresAuth: true, so they are hidden while signed out. The sign-in page shows no tab bar.
  • For a public home screen, remove the '/**' rule and remove requiresAuth from the Home tab. Guard single pages instead, for example '/account': { authenticated: true }.
These rules only decide what the app shows. The tokens and the user live on the device, where their owner can change them. Keep privileged data out of the bundle, and let your API decide with requireAuth or requireUser.

Rename the app

Everything that names the app is in apps/mobile/identity.ts:

apps/mobile/identity.ts
export const appId = 'com.example.mobile' // bundle id, application id, and (lowercase) the redirect URI scheme
export const appName = 'Example' // home screen name and fallback page title
export const clientId = 'mobile' // the OAuth client_id
export const callbackPath = '/auth/callback'
export const devPort = 3001 // keep the `dev` script of apps/mobile/package.json in step
export const scopes = ['openid', 'profile', 'email'] // add 'offline_access' for issuers that need it
export const nativeRedirectUri = `${appId.toLowerCase().replaceAll('_', '-')}:${callbackPath}`
export const devRedirectUri = `http://localhost:${devPort}${callbackPath}`

After changing appId:

  1. Register the new redirect URI (<new app id>:/auth/callback) at the issuer. An OAuth Server in the same workspace picks it up from @workspace/mobile/identity instead.
  2. Delete apps/mobile/ios and apps/mobile/android. Then run pnpm mobile:native, or any mobile:ios / mobile:android script, to create them with the new id and URL scheme.
Use lowercase letters, digits, . and - in the app id. The redirect URI scheme is the app id in lowercase, with _ turned into -.

The tabs are mobile.tabs in apps/mobile/nuxt.config.ts. The sign-in page's title and icon are in apps/mobile/app/app.config.ts (authentication.login).

What is in the starter

PathWhat it does
package.jsonRoot scripts: dev, build, lint, lint:fix, typecheck, test, prepare:env, clean and mobile:*.
pnpm-workspace.yamlLists apps/* and packages/*.
AGENTS.mdConventions for you and your coding agents.
scripts/prepare-env.mjsCreates the apps' .env files.
packages/eslint-config/Shared lint config for packages you add.
apps/mobile/identity.tsApp id, name, client id, callback path, dev port and scopes. Exported as @workspace/mobile/identity.
apps/mobile/nuxt.config.ts@nuxt/ui, @frigear-nu/mobile and @frigear-nu/authorization, the tabs, the '/**' route rule, and authentication in client mode from identity.ts.
apps/mobile/.env.exampleNUXT_PUBLIC_AUTHENTICATION_CLIENT_ISSUER and _API_BASE.
apps/mobile/app/app.vue<UApp :toaster="useMobileToaster()">.
apps/mobile/app/app.config.tsSign-in page branding (authentication.login).
apps/mobile/app/pages/index.vueHome: useUserSession().user and useApi('/api/me'), refreshed with onTabEnter.
apps/mobile/app/pages/account.vueAccount: profile and sign-out.

ios/ and android/ are not in the starter: pnpm mobile:native creates them. Commit them once you change native settings, such as the signing team, icons or entitlements.

The app's package.json lists:

  • the Capacitor plugins the shell uses: @capacitor/app, browser, haptics, action-sheet, dialog, share, status-bar, keyboard, preferences and @capgo/capacitor-native-navigation,
  • @aparajita/capacitor-secure-storage for the tokens,
  • @frigear-nu/mobile, @frigear-nu/auth and @frigear-nu/authorization.
Capacitor only links the native code of the app's own dependencies. Add plugins to apps/mobile, not to a package it uses.

Add apps/mobile to an existing workspace

To add the app to a project made from the workspace starter, or any pnpm workspace with apps/*, copy just the app:

pnpx @frigear-nu/cli app:create mobile --template mobile
pnpm prepare:env

pnpm prepare:env creates apps/mobile/.env too: scripts/prepare-env.mjs handles every app with an .env.example. If your project predates it, copy the script from a current starter. Add the root scripts you want from the standalone starter, such as dev:mobile, mobile:native and mobile:ios.

To make apps/web the issuer and API of the app, apply the difference between the two starters. It is all in apps/web:

Install OAuth Server and the app's identity

pnpm --filter ./apps/web add @frigear-nu/oauth-server @workspace/mobile@workspace:*

Register the app as a configured client

A configured client is a public OAuth application defined in config. Add it in apps/web/nuxt.config.ts:

apps/web/nuxt.config.ts
import { appName, clientId, devRedirectUri, nativeRedirectUri } from '@workspace/mobile/identity'

export default defineNuxtConfig({
  modules: [
    // ...
    '@frigear-nu/oauth-server',
  ],
  oauthServer: {
    cors: {
      origins: ['capacitor://localhost', 'https://localhost'],
      paths: ['/api/'],
    },
    clients: [{
      id: clientId,
      name: appName,
      redirectUris: [nativeRedirectUri],
      devRedirectUris: [devRedirectUri],
    }],
    settings: { pages: { admin: true } },
  },
})

Add an API route

The workspace starter's roles decide who manages OAuth applications: admin holds oauth:manage, and an application's allowedRoles resolves through the same roles. No plugin is needed for either.

Copy apps/web/server/api/me.get.ts from .starters/mobile-standalone (on GitHub): the API route the app calls, with requireUser.

Add a signing key

Add NUXT_OAUTH_SERVER_PRIVATE_KEY= to apps/web/.env.example, and generate a key (Signing keys). nuxt dev works without one: it keeps a dev key in .data/oauth-server/dev-key.pem.

Generate the migration

OAuth Server adds tables. Run pnpm db:generate and commit the migration.

The standalone starter guide explains each piece.

Production checklist

Never ship a localhost or LAN address, and never register the localhost redirect URI in production.
  • Set your own appId, appName and clientId in identity.ts, and recreate ios/ and android/.
  • Register the production redirect URI <app id>:/auth/callback at the issuer.
  • Point NUXT_PUBLIC_AUTHENTICATION_CLIENT_ISSUER and NUXT_PUBLIC_AUTHENTICATION_CLIENT_API_BASE at https production URLs, in the environment of the release build.
  • Allow capacitor://localhost and https://localhost for CORS at the issuer and on your API.
  • Build releases with MOBILE_RELEASE=1, for example MOBILE_RELEASE=1 pnpm mobile:sync, so MOBILE_LAN cannot slip in.
  • Keep @aparajita/capacitor-secure-storage installed. Without it a production build fails, because tokens would be stored unencrypted.
  • Check the route rules. Nothing is protected without them, and your API checks every protected request.
  • Run pnpm mobile:doctor and fix what it reports. For CI, frigear-mobile doctor --strict exits with 1 on errors.
  • Replace the app icon and splash screen in the native projects, set the version and build number, and set up signing for the App Store and Google Play.

The Mobile release checklist has the details.

Next steps

Copyright © 2026