Introduction
Want to build a mobile app with Nuxt from the ground up? This guide walks you through creating a brand new Nuxt 4 project configured for mobile from day one, then packaging it as native iOS and Android apps using Capacitor 8.
By the end of this tutorial, you’ll have a working mobile app running on simulators that you can continue developing and eventually publish to the App Store and Google Play.
Time required: ~30 minutes
What you’ll build:
- A new Nuxt 4 project with the latest directory structure
- Static generation configuration for mobile
- Capacitor 8 with essential plugins
- Native iOS and Android apps
- Live reload development setup
Already have a Nuxt app? Check out Convert Your Nuxt App to Mobile instead.
Prerequisites
Make sure you have these installed:
- Node.js 18+ (check with
node --version) - Bun package manager (
curl -fsSL https://bun.sh/install | bash) - Xcode (macOS only, for iOS development)
- Android Studio (for Android development)
Step 1: Create a New Nuxt 4 Project
Start by creating a fresh Nuxt 4 project:
bunx nuxi@latest init my-mobile-app
cd my-mobile-app
bun install
Nuxt 4 Directory Structure
Nuxt 4 uses a new directory structure with app code in the app/ directory:
my-mobile-app/
app/
assets/
components/
composables/
layouts/
middleware/
pages/
plugins/
utils/
app.vue
public/
server/
nuxt.config.ts
package.json
This structure provides better separation between app and server code.
Step 2: Configure Nuxt for Static Generation
Capacitor requires static HTML/JS/CSS files. Configure Nuxt for static generation in nuxt.config.ts:
export default defineNuxtConfig({
compatibilityDate: '2025-01-15',
devtools: { enabled: true },
// Enable static generation
ssr: true,
nitro: {
preset: 'static',
},
});
Step 3: Add Mobile Scripts
Update your package.json with mobile development scripts:
{
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview",
"mobile": "bun run generate && bunx cap sync",
"mobile:ios": "bun run mobile && bunx cap open ios",
"mobile:android": "bun run mobile && bunx cap open android"
}
}
Test the static generation:
bun run generate
You should see a .output/public directory with your static files.
Step 4: Install Capacitor 8
Install the Capacitor core packages:
bun add @capacitor/core
bun add -D @capacitor/cli
Install essential plugins that most mobile apps need:
bun add @capacitor/app @capacitor/keyboard @capacitor/splash-screen @capacitor/status-bar @capacitor/preferences
What these plugins do:
- @capacitor/app — App lifecycle events (foreground/background, deep links)
- @capacitor/keyboard — Control keyboard behavior
- @capacitor/splash-screen — Native splash screen control
- @capacitor/status-bar — Style the device status bar
- @capacitor/preferences — Key-value storage (like localStorage but native)
Step 5: Initialize Capacitor
Initialize Capacitor with your project details:
bunx cap init "My Mobile App" com.example.mymobileapp --web-dir .output/public
Replace:
"My Mobile App"with your app’s display namecom.example.mymobileappwith your app ID (reverse domain notation)
This creates capacitor.config.ts. Update it with plugin configuration:
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.example.mymobileapp',
appName: 'My Mobile App',
webDir: '.output/public',
plugins: {
SplashScreen: {
launchShowDuration: 2000,
launchAutoHide: true,
androidScaleType: 'CENTER_CROP',
splashFullScreen: true,
splashImmersive: true,
},
Keyboard: {
resize: 'body',
resizeOnFullScreen: true,
},
StatusBar: {
style: 'dark',
},
},
};
export default config;
Step 6: Add Native Platforms
Install the platform packages:
bun add @capacitor/ios @capacitor/android
Generate the native projects:
bunx cap add ios
bunx cap add android
This creates ios and android directories containing the native projects.
Step 7: Build and Run
Build your project and sync with native platforms:
bun run mobile
Open in iOS Simulator:
bun run mobile:ios
Or Android Emulator:
bun run mobile:android
In Xcode (iOS):
- Select a simulator from the device dropdown
- Click the Play button or press
Cmd + R
In Android Studio:
- Wait for Gradle to finish syncing
- Select an emulator from the device dropdown
- Click the Run button or press
Shift + F10
Step 8: Set Up Live Reload
For faster development, enable live reload so changes appear instantly on your device.
- Find your local IP address:
# macOS
ipconfig getifaddr en0
# Windows
ipconfig
- Create a development Capacitor config. Update
capacitor.config.ts:
import type { CapacitorConfig } from '@capacitor/cli';
const devConfig: CapacitorConfig = {
appId: 'com.example.mymobileapp',
appName: 'My Mobile App',
webDir: '.output/public',
server: {
url: 'http://YOUR_IP_ADDRESS:3000',
cleartext: true,
},
plugins: {
// ... same plugin config
},
};
const prodConfig: CapacitorConfig = {
appId: 'com.example.mymobileapp',
appName: 'My Mobile App',
webDir: '.output/public',
plugins: {
// ... same plugin config
},
};
const config = process.env.NODE_ENV === 'development' ? devConfig : prodConfig;
export default config;
- Start the dev server and copy config to native:
bun run dev &
NODE_ENV=development bunx cap copy
- Rebuild in Xcode/Android Studio
Now edits to your Nuxt code will hot-reload on the device.
Step 9: Create Your First Mobile Screen
Let’s create a mobile-friendly home screen. Update app/app.vue:
<template>
<NuxtPage />
</template>
Create app/pages/index.vue:
<template>
<main
class="min-h-screen bg-linear-to-b from-green-500 to-green-700 flex flex-col items-center justify-center p-6 text-white"
>
<h1 class="text-4xl font-bold mb-4">My Mobile App</h1>
<p class="text-xl mb-8 text-center opacity-90">
Built with Nuxt 4 + Capacitor 8
</p>
<div v-if="appInfo" class="bg-white/20 rounded-lg p-4 backdrop-blur-sm mb-8">
<p class="text-sm">
{{ appInfo.name }} v{{ appInfo.version }}
</p>
</div>
<div class="space-y-4 w-full max-w-sm">
<button
class="w-full py-4 px-6 bg-white text-green-600 rounded-xl font-semibold text-lg shadow-lg active:scale-95 transition-transform"
@click="handleGetStarted"
>
Get Started
</button>
<button
class="w-full py-4 px-6 bg-white/20 text-white rounded-xl font-semibold text-lg backdrop-blur-sm active:scale-95 transition-transform"
@click="handleShare"
>
Share App
</button>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { App } from '@capacitor/app';
const appInfo = ref<{ name: string; version: string } | null>(null);
let backButtonListener: { remove: () => void } | null = null;
onMounted(async () => {
// Get app info
try {
appInfo.value = await App.getInfo();
} catch (e) {
// Web fallback
appInfo.value = { name: 'My Mobile App', version: '1.0.0' };
}
// Handle Android back button
backButtonListener = await App.addListener('backButton', ({ canGoBack }) => {
if (!canGoBack) {
App.exitApp();
} else {
window.history.back();
}
});
});
onUnmounted(() => {
backButtonListener?.remove();
});
function handleGetStarted() {
// Navigate to onboarding or main app
console.log('Get started clicked');
}
async function handleShare() {
// We'll implement this with the Share plugin later
console.log('Share clicked');
}
</script>
Step 10: Add Tailwind CSS
For the styling to work, add Tailwind CSS to your project:
bun add tailwindcss @tailwindcss/vite
Update nuxt.config.ts:
import tailwindcss from '@tailwindcss/vite';
export default defineNuxtConfig({
compatibilityDate: '2025-01-15',
devtools: { enabled: true },
ssr: true,
nitro: {
preset: 'static',
},
css: ['~/assets/css/main.css'],
vite: {
plugins: [tailwindcss()],
},
});
Create app/assets/css/main.css:
@import 'tailwindcss';
:root {
--sat: env(safe-area-inset-top);
--sar: env(safe-area-inset-right);
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
}
body {
padding-top: var(--sat);
padding-right: var(--sar);
padding-bottom: var(--sab);
padding-left: var(--sal);
}
/* Prevent text selection on mobile */
* {
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
/* Allow text selection in inputs */
input,
textarea {
-webkit-user-select: auto;
user-select: auto;
}
Step 11: Add the Share Plugin
Let’s implement the share button functionality:
bun add @capacitor/share
Update app/pages/index.vue to use the Share plugin:
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { App } from '@capacitor/app';
import { Share } from '@capacitor/share';
// ... existing code ...
async function handleShare() {
try {
await Share.share({
title: 'Check out this app!',
text: 'Built with Nuxt 4 and Capacitor 8',
url: 'https://capacitorjs.com',
dialogTitle: 'Share with friends',
});
} catch (e) {
console.log('Share cancelled or failed:', e);
}
}
</script>
Sync and rebuild:
bun run mobile
Project Structure
Your project should now look like this:
my-mobile-app/
├── android/ # Android native project
├── ios/ # iOS native project
├── .output/
│ └── public/ # Static build output
├── app/
│ ├── assets/
│ │ └── css/
│ │ └── main.css
│ ├── pages/
│ │ └── index.vue
│ └── app.vue
├── capacitor.config.ts # Capacitor configuration
├── nuxt.config.ts # Nuxt configuration
├── package.json
└── ...
Next Steps
You now have a working Nuxt mobile app. Here’s what to do next:
Essential Setup
- App Icons: Replace default icons in
ios/App/App/Assets.xcassetsandandroid/app/src/main/res - Splash Screen: Customize in native projects or use
@capacitor/splash-screenconfig - Deep Links: Configure URL schemes for your app
Add More Features
- Camera:
bun add @capacitor/camera - Geolocation:
bun add @capacitor/geolocation - Push Notifications:
bun add @capacitor/push-notificationsor @capgo/capacitor-firebase-messaging for Firebase Cloud Messaging on iOS and Android - File System:
bun add @capacitor/filesystem
Native UI and transitions
Use Capgo plugins instead of Konsta UI for a native mobile feel:
- @capgo/capacitor-native-navigation — Liquid Glass tab bar and native navbar
- @capgo/capacitor-transitions — native-feeling page transitions
bun add @capgo/capacitor-native-navigation @capgo/capacitor-transitions
bunx cap sync
For Tailwind safe areas, add @capgo/tailwind-capacitor:
bun add -D tailwind-capacitor
See Using @capgo/capacitor-native-navigation, Using @capgo/capacitor-transitions, and the tailwind-capacitor repo for Nuxt-specific setup.
Fixing iOS Layout Issues (Viewport, Safe Area, and Horizontal Overflow)
If content looks cropped, shifted, or horizontally scrollable on iOS, adding more overflow-x: hidden or tweaking the viewport tag alone usually does not fix it. Work through these checks in order.
Make sure the viewport meta tag is applied correctly
In nuxt.config.ts, set the viewport through app.head:
export default defineNuxtConfig({
app: {
head: {
meta: [
{
name: 'viewport',
content: 'width=device-width, initial-scale=1, viewport-fit=cover',
},
],
},
},
});
Handle iOS safe area from one root wrapper only
Create a single app shell and apply safe area padding there — not in multiple nested components:
html,
body,
#__nuxt {
width: 100%;
min-height: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
}
* {
box-sizing: border-box;
}
.app-shell {
min-height: 100dvh;
width: 100%;
padding-top: env(safe-area-inset-top);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
}
Wrap all page content inside .app-shell. Duplicated safe-area padding in headers, modals, and layout wrappers often makes the UI look cropped or too large.
With @capgo/tailwind-capacitor, you can express the same padding with utilities like pt-safe pb-safe px-safe on that single shell.
Set Capacitor iOS contentInset to never first
In capacitor.config.ts, prefer native inset disabled and let CSS (or Native Navigation’s contentInsetMode: 'css') own the safe area:
const config: CapacitorConfig = {
appId: 'com.example.myapp',
appName: 'my-app',
webDir: '.output/public',
ios: {
contentInset: 'never',
},
};
Mixing Capacitor’s automatic content inset with CSS env(safe-area-inset-*) padding is a common cause of double spacing.
Find the real overflowing element
The usual culprit is an element using 100vw, Tailwind w-screen, a fixed pixel width, or a large min-width.
In Safari Web Inspector, run:
[...document.querySelectorAll('*')]
.filter(el => el.scrollWidth > document.documentElement.clientWidth)
.map(el => ({
el,
tag: el.tagName,
class: el.className,
scrollWidth: el.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
With Tailwind, replace w-screen with w-full when possible. Many horizontal overflow issues come from 100vw / w-screen, duplicated safe-area padding, or a fixed-width container — not from the viewport meta tag itself.
Over-the-Air Updates
Set up Capgo to push updates without app store resubmission:
bunx @capgo/cli init
Troubleshooting
Build fails with “Cannot find module”
Run bun install and try again.
iOS: “No signing identity found” Open Xcode, go to Signing & Capabilities, and select your development team.
Android: “SDK location not found”
Create android/local.properties with sdk.dir=/path/to/android/sdk
Changes not appearing on device
Make sure you ran bun run mobile after making changes. For live reload, verify the IP address is correct and the dev server is running.
.output/public is empty or missing
Make sure you configured nitro: { preset: 'static' } in nuxt.config.ts and run bun run generate.
Resources
- Capacitor 8 Documentation
- Nuxt 4 Documentation
- Capgo - Live Updates
- @capgo/capacitor-native-navigation
- @capgo/capacitor-transitions
- @capgo/tailwind-capacitor
Ready to ship your app? Learn how Capgo can help you deliver updates faster — sign up for a free account today.
Keep going from Build a Nuxt Mobile App from Scratch with Capacitor 8
If you are using Build a Nuxt Mobile App from Scratch with Capacitor 8 to plan CI/CD automation, connect it with Capgo CI/CD for the product workflow in Capgo CI/CD, Capgo Native Builds for the product workflow in Capgo Native Builds, Capgo Integrations for the product workflow in Capgo Integrations, CI/CD Integration for the implementation detail in CI/CD Integration, and GitHub Actions Integration for the implementation detail in GitHub Actions Integration.