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/meon 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.
- Mobile is the app shell.
- Auth in client mode signs the user in and keeps the tokens.
- Authorization provides the guards.
Prerequisites
The tools in Requirements, plus:
| Tool | For |
|---|---|
| Xcode | iOS: a recent Xcode with an iOS simulator. The native project uses Swift Package Manager, no CocoaPods. |
| Android Studio | Android: 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
pnpx giget@latest gh:Frigear-nu/frigear-nu/.starters/mobile my-app
cd my-app
git init
corepack enable
pnpm install
pnpm prepare:env
pnpm prepare:env copies apps/mobile/.env.example to apps/mobile/.env. It never overwrites a value.
2. Choose the issuer
Set the issuer and your API in apps/mobile/.env:
| Variable | What it is |
|---|---|
NUXT_PUBLIC_AUTHENTICATION_CLIENT_ISSUER | The issuer URL, for example https://frigear.nu. The example file sets http://localhost:3000. |
NUXT_PUBLIC_AUTHENTICATION_CLIENT_API_BASE | The API that useApi() and $api call with the access token. Without it: the issuer. |
NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_ID | Optional. Overrides clientId of identity.ts per environment. |
NUXT_PUBLIC_AUTHENTICATION_CLIENT_SCOPE | Optional. 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:
| What | Value in the starter |
|---|---|
| Client id | mobile (clientId) |
| Redirect URI of the app | com.example.mobile:/auth/callback (appId in lowercase, then callbackPath) |
Redirect URI for pnpm dev | http://localhost:3001/auth/callback (development only) |
| Cross-origin resource sharing (CORS) origins | capacitor://localhost (iOS), https://localhost (Android), http://localhost:3001 (development) |
| Scopes | openid 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_accessscope and drops it. - Most other issuers only issue one for
offline_access. For Keycloak, Auth0, Zitadel and the like, add it toscopesinidentity.ts, or toNUXT_PUBLIC_AUTHENTICATION_CLIENT_SCOPE.
openid in the scopes. The signed-in user comes from the ID token.How that looks for each kind of issuer:
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.Staff of frigear.nu register the OAuth application, so ask one of them. Its CORS allows the webview origins, so the app
works on a device. A browser on http://localhost:3001 is not an allowed origin there.
frigear.nu has no /api/me. Point NUXT_PUBLIC_AUTHENTICATION_CLIENT_API_BASE at your own API, or the Home tab shows
the failed call.
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:
- Sign in sends you to the issuer, where you sign in and approve the app. Passkeys work when the issuer offers
them, as the
workspaceandmobile-standalonestarters do. - The issuer redirects to
http://localhost:3001/auth/callback. The app exchanges the code for tokens and opens Home. - 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.
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:
- The first time, it creates the native projects (
frigear-mobile native --if-missing). That runscap add iosandcap add android, and adds the URL scheme of the redirect URI toInfo.plistandAndroidManifest.xml. - It builds the SPA (
nuxt generate). - It copies the SPA into
ios/(cap sync ios). - 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.
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:
- 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.
- Connect the phone and turn on Developer Mode on it (Settings, Privacy & Security). Choose it as the run destination and press Run.
- 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.
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:
routeRules: {
'/**': { authenticated: true },
},
authorization: { redirectTo: '/login' },
/loginand 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 removerequiresAuthfrom the Home tab. Guard single pages instead, for example'/account': { authenticated: true }.
requireAuth or requireUser.Rename the app
Everything that names the app is in 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:
- 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/identityinstead. - Delete
apps/mobile/iosandapps/mobile/android. Then runpnpm mobile:native, or anymobile:ios/mobile:androidscript, to create them with the new id and URL scheme.
. 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
| Path | What it does |
|---|---|
package.json | Root scripts: dev, build, lint, lint:fix, typecheck, test, prepare:env, clean and mobile:*. |
pnpm-workspace.yaml | Lists apps/* and packages/*. |
AGENTS.md | Conventions for you and your coding agents. |
scripts/prepare-env.mjs | Creates the apps' .env files. |
packages/eslint-config/ | Shared lint config for packages you add. |
apps/mobile/identity.ts | App 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.example | NUXT_PUBLIC_AUTHENTICATION_CLIENT_ISSUER and _API_BASE. |
apps/mobile/app/app.vue | <UApp :toaster="useMobileToaster()">. |
apps/mobile/app/app.config.ts | Sign-in page branding (authentication.login). |
apps/mobile/app/pages/index.vue | Home: useUserSession().user and useApi('/api/me'), refreshed with onTabEnter. |
apps/mobile/app/pages/account.vue | Account: 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,preferencesand@capgo/capacitor-native-navigation, @aparajita/capacitor-secure-storagefor the tokens,@frigear-nu/mobile,@frigear-nu/authand@frigear-nu/authorization.
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
pnpx giget@latest gh:Frigear-nu/frigear-nu/.starters/mobile/apps/mobile apps/mobile
pnpm install
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:
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
localhost or LAN address, and never register the localhost redirect URI in production.- Set your own
appId,appNameandclientIdinidentity.ts, and recreateios/andandroid/. - Register the production redirect URI
<app id>:/auth/callbackat the issuer. - Point
NUXT_PUBLIC_AUTHENTICATION_CLIENT_ISSUERandNUXT_PUBLIC_AUTHENTICATION_CLIENT_API_BASEathttpsproduction URLs, in the environment of the release build. - Allow
capacitor://localhostandhttps://localhostfor CORS at the issuer and on your API. - Build releases with
MOBILE_RELEASE=1, for exampleMOBILE_RELEASE=1 pnpm mobile:sync, soMOBILE_LANcannot slip in. - Keep
@aparajita/capacitor-secure-storageinstalled. 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:doctorand fix what it reports. For CI,frigear-mobile doctor --strictexits with1on 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
- Mobile – tabs, page meta, native chrome,
useNative(), deep links and the CLI. - Auth in client mode –
useUserSession(),useAuth(),$api, token storage and the options. - Authorization – route rules and permissions for the app's guards.
- Use your own OAuth server – point the app at another issuer.
Workspace starter
A pnpm workspace with one Nuxt 4 app that has registration, sign-in, account pages and a user admin, built on the published Frigear modules.
Standalone mobile starter
Start a mobile app with its own backend - a Nuxt 4 web app that is the OAuth server and API, and a Capacitor app that signs in at it - from the standalone mobile starter.