Starters

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.

What you'll have afterwards:

  • A web app on http://localhost:3000 with sign-up, sign-in, account pages and an admin area, signed in with the admin role.
  • A mobile app on http://localhost:3001, and on iOS and Android, that signs in at that web app.
  • An API route, /api/me, that the mobile app calls with its access token.

Use this starter when your app needs its own users and API, with nothing else to run. It is the workspace starter plus the app of the mobile starter, wired together:

  • apps/web has sign-up and sign-in, /account pages and an admin area with user management. With OAuth Server it is also the issuer and the API of the mobile app.
  • apps/mobile is a Capacitor app on Mobile. It signs in at apps/web with Auth in client mode, and picks its guards with Authorization. It calls /api/me with its access token.
There is nothing to register by hand. apps/web reads the app's client id and redirect URIs from apps/mobile/identity.ts.

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-standalone 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 web,mobile
cd my-app
git init

pnpm prepare:env creates apps/web/.env and apps/mobile/.env from their examples, and generates the secrets. It never overwrites a value, so it is safe to run again.

VariableWhereWhat it is
NUXT_APP_SECRETapps/web/.envSeals the session cookie (Nuxt's appSecret). Generated; 32+ characters in production.
NUXT_OAUTH_SERVER_PRIVATE_KEYapps/web/.envSigns the OAuth tokens. A PKCS#8 RSA key, generated.
NUXT_ACL_ADMINSapps/web/.envComma separated emails that always get the admin role once verified. Put your own email here.
NUXT_PUBLIC_AUTHENTICATION_CLIENT_ISSUERapps/mobile/.envhttp://localhost:3000, the web app.
NUXT_PUBLIC_AUTHENTICATION_CLIENT_API_BASEapps/mobile/.envhttp://localhost:3000, the web app's API.

2. Run both apps

pnpm dev

This starts apps/web on http://localhost:3000 and apps/mobile on http://localhost:3001. On the first start, NuxtHub creates a local SQLite database in apps/web/.data/. It applies the committed migrations, including the tables of OAuth Server.

3. Sign in

Put your email in NUXT_ACL_ADMINS in apps/web/.env before you start the app. An email added later counts after a restart of pnpm dev.
  1. Open http://localhost:3000, choose Create account and sign up with the email you put in NUXT_ACL_ADMINS. Signing up sends a verification email (verifyEmail is on), printed in the terminal that runs pnpm dev: open its link and press Continue to sign in. The list only counts for a verified address.
  2. /admin is the admin dashboard. /admin/oauth lists the OAuth applications. The mobile app's mobile application is marked Managed in nuxt.config. On Security (/account/security) you can add a passkey, which the mobile app's sign-in then offers too.
  3. Open http://localhost:3001, the mobile app in a browser. Its '/**': { authenticated: true } route rule sends you to its /login page.
  4. Sign in sends you to apps/web, which asks you to approve the app. It then returns to http://localhost:3001/auth/callback.
  5. On Account, Sign out revokes the refresh token and returns to /. The route rule sends you on to the sign-in page.
What you should see:Home shows your sign-in (useUserSession().user, from the ID token) and the result of GET /api/me, called cross-origin with the access token. A reload keeps you signed in.

Both apps use the same app API: useUserSession(), useAuth() and $api / useApi().

The app's guards only decide what it shows. apps/web checks every API request.

Emails such as password resets are printed in the terminal that runs pnpm dev, by the log driver of Notify. They stay there until you configure a real driver.

4. Run it on the iOS simulator

Keep the web app running (pnpm dev, or pnpm dev:web). Then, in a second terminal:

pnpm mobile:ios

The script creates the native projects the first time: cap add ios and cap add android, plus the URL scheme com.example.mobile in Info.plist and AndroidManifest.xml. It builds the SPA, syncs it into ios/ and asks which simulator to run.

The simulator shares your Mac's network, so the app reaches http://localhost:3000. Sign in opens the in-app browser at apps/web. It has its own cookies, so you sign in there once. After you approve, the redirect to com.example.mobile:/auth/callback returns to the app, signed in.

Run pnpm mobile:sync after every change to the app or its .env, and after every pnpm install.

5. Run it on an iPhone

A phone cannot reach localhost on your Mac. Start the web app on every interface, and build the app against your Mac's local network (LAN) address:

pnpm dev:lan             # apps/web on 0.0.0.0:3000
pnpm mobile:ios:device   # MOBILE_LAN=1 build, then opens Xcode

MOBILE_LAN=1 rewrites localhost in the issuer and API base to this Mac's LAN address. MOBILE_LAN=192.168.1.20 picks the address. OAuth Server takes its issuer from the request, so it matches.

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.
The phone and the Mac must be on the same network. A LAN build never ships: the build fails when MOBILE_RELEASE=1 is also set.

6. Run it on Android

pnpm mobile:android

The steps are the same as for iOS.

The Android WebView runs at https://localhost, so calls to the plain http dev server are mixed content. Test Android against an https deployment of apps/web, or use live reload (see Device development).

What is in the starter

apps/mobile is the mobile starter's app, file for file. apps/web is the workspace starter's app, except for the files below. The workspace starter and the mobile starter list those files. This starter adds:

PathWhat it does
apps/web/nuxt.config.tsAdds OAuth Server and registers the mobile app as a configured client.
apps/web/server/api/me.get.tsThe API route the mobile app calls.
apps/web/.env.exampleAdds NUXT_OAUTH_SERVER_PRIVATE_KEY.
apps/web/package.jsonAdds @frigear-nu/oauth-server and @workspace/mobile.
apps/web/server/db/migrations/Also creates the tables of OAuth Server.
apps/web/AGENTS.mdConventions that include OAuth Server.

How the two apps fit together

apps/mobile/identity.ts is exported as @workspace/mobile/identity. apps/web depends on @workspace/mobile to read it:

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

export default defineNuxtConfig({
  modules: [
    // ... the workspace starter's modules
    '@frigear-nu/oauth-server',
  ],
  oauthServer: {
    cors: {
      // The webviews of the native app: iOS (capacitor://localhost) and Android (https://localhost).
      origins: ['capacitor://localhost', 'https://localhost'],
      // Your API: the same origins may call it with `Authorization: Bearer`.
      paths: ['/api/'],
    },
    clients: [{
      id: clientId, // 'mobile'
      name: appName,
      redirectUris: [nativeRedirectUri], // 'com.example.mobile:/auth/callback'
      devRedirectUris: [devRedirectUri], // 'http://localhost:3001/auth/callback', under `nuxt dev` only
    }],
    settings: { pages: { admin: true } },
  },
})
PieceWhat it does
oauthServer.clientsA configured client: a public OAuth application with the fixed id mobile.
cors.originsThe webview origins may call the token, revoke, userinfo and discovery endpoints.
cors.pathsThe same origins may call /api/** with a bearer token. Cookies never travel cross-origin.
server/api/me.get.tsReturns an allowlisted { id, name, email } for the signed-in user.
NUXT_OAUTH_SERVER_PRIVATE_KEYThe key that signs tokens.

oauthServer.clients. The configured client uses PKCE (Proof Key for Code Exchange) and has no secret. It is created in the database on the first OAuth request. The dev redirect URI, and its origin for CORS (cross-origin resource sharing), only exist under nuxt dev, so production never accepts them. NUXT_OAUTH_SERVER_CLIENTS (JSON) replaces the list per environment. Create it in nuxt.config has the details.

server/api/me.get.ts. requireUser(event) accepts the session cookie and the access token. Write your API routes the same way.

Who may do what. OAuth Server registers its permissions (oauth:read, oauth:manage), and the starter's admin role holds them through grant: ['*'], so admins manage the applications at /admin/oauth. An application's allowedRoles is checked against the user's roles from authorization.roles, the NUXT_ACL_ADMINS admins included, so no plugin is needed. The mobile application has no allowedRoles, so every signed-in user may use it. An access token holds the permissions of its scopes that the user has, frozen when it is issued, and every request is still checked against the user's current permissions.

NUXT_OAUTH_SERVER_PRIVATE_KEY. Without it, nuxt dev keeps a dev key in apps/web/.data/oauth-server/dev-key.pem.

A production server without NUXT_OAUTH_SERVER_PRIVATE_KEY answers 500.

To register more OAuth applications, such as another app or a confidential web app, or to change this one, see Create an OAuth application.

Rename the app

Change apps/mobile/identity.ts (app id, name, client id, scopes). The mobile starter guide lists the fields. Then:

  1. Restart pnpm dev. apps/web reads the new client id and redirect URIs from the same file. The old OAuth application stays in the database as an ordinary application, so its users stay signed in. Delete it in /admin/oauth when you no longer need it.
  2. Delete apps/mobile/ios and apps/mobile/android. Then run pnpm mobile:native (or pnpm mobile:ios) to create them with the new id and URL scheme.

Commands

Run everything from the project root:

CommandWhat it does
pnpm prepare:envCreates each app's .env from .env.example and fills in empty secrets.
pnpm devapps/web on http://localhost:3000 and apps/mobile on http://localhost:3001.
pnpm dev:web / pnpm dev:mobileOne of them.
pnpm dev:lanapps/web on every interface, for a phone on the same network.
pnpm buildBuilds every app and package.
pnpm previewPreviews the apps/web build.
pnpm lint / pnpm lint:fix / pnpm typecheck / pnpm testChecks across the workspace.
pnpm db:generateGenerates a migration after adding or upgrading a module. Commit it.
pnpm db:migrateApplies migrations without starting the dev server.
pnpm mobile:nativeCreates ios/ and android/ and registers the URL scheme. Safe to rerun.
pnpm mobile:doctorLists missing Capacitor plugins and URL schemes.
pnpm mobile:syncRuns nuxt generate and cap sync. Run it after every app change.
pnpm mobile:iosBuilds and runs on an iOS simulator.
pnpm mobile:ios:deviceBuilds against this Mac's LAN address and opens Xcode, to run on a phone.
pnpm mobile:androidBuilds and runs on an Android emulator or device.
pnpm cleanRuns each package's clean script, then removes the root node_modules and .data.

Production checklist

Do everything in Deployment for apps/web: the database and its migrations, NUXT_APP_SECRET, NUXT_ACL_ADMINS and a real Notify driver. Then add the items below.

Use a new NUXT_OAUTH_SERVER_PRIVATE_KEY per environment. Never reuse the one in your local .env (Signing keys).

The server:

  • Set oauthServer.issuer to the public https origin, so tokens do not depend on the request host behind a proxy.
  • Set NUXT_OAUTH_SERVER_PRIVATE_KEY in your hosting platform's environment, as above.
  • Serve apps/web on its final https domain. A passkey belongs to the domain it was created on, so moving means users add theirs again.

The app:

  • Set your own appId, appName and clientId in identity.ts, and recreate ios/ and android/.
  • Point NUXT_PUBLIC_AUTHENTICATION_CLIENT_ISSUER and NUXT_PUBLIC_AUTHENTICATION_CLIENT_API_BASE at the production server in the environment of the release build. Build with MOBILE_RELEASE=1.
  • Keep @aparajita/capacitor-secure-storage installed, and run pnpm mobile:doctor.
  • Replace the icon and splash screen, set the version and build number, and set up signing for the App Store and Google Play.

The Mobile release checklist and the OAuth Server page have the details.

Next steps

  • Add a module – add a feature to apps/web, with the same steps as any app.
  • OAuth Server – scopes, claims, roles and the admin pages for OAuth applications.
  • Auth in client mode – the app's sign-in, tokens and options.
  • UX patterns – follow the shared patterns when you add screens.
Copyright © 2026