Introduction
Want to build a mobile app with Next.js from the ground up? This guide walks you through creating a brand new Next.js 15 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 Next.js 15 project with App Router
- Static export configuration for mobile
- Capacitor 8 with essential plugins
- Native iOS and Android apps
- Live reload development setup
Already have a Next.js app? Check out Convert Your Next.js 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 Next.js Project
Start by creating a fresh Next.js 15 project:
bunx create-next-app@latest my-mobile-app
When prompted, select these options:
- TypeScript: Yes (recommended)
- ESLint: Yes
- Tailwind CSS: Yes (recommended for mobile styling)
src/directory: Yes- App Router: Yes (recommended)
- Import alias: Default (
@/*)
Navigate to your project:
cd my-mobile-app
Step 2: Configure Next.js for Static Export
Capacitor requires static HTML/JS/CSS files. Configure Next.js for static export by updating next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'export',
images: {
unoptimized: true,
},
// Ensure trailing slashes for proper routing in Capacitor
trailingSlash: true,
};
export default nextConfig;
Why these settings?
output: 'export'— Generates static HTML instead of requiring a Node.js serverimages: { unoptimized: true }— Disables Next.js Image Optimization (requires a server)trailingSlash: true— Ensures proper routing in the native WebView
Step 3: Add Mobile Scripts
Update your package.json with mobile development scripts:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"mobile": "bun run build && bunx cap sync",
"mobile:ios": "bun run mobile && bunx cap open ios",
"mobile:android": "bun run mobile && bunx cap open android"
}
}
Test the build:
bun run build
You should see an out 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 out
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: 'out',
plugins: {
SplashScreen: {
launchShowDuration: 2000,
launchAutoHide: true,
androidScaleType: 'CENTER_CROP',
splashFullScreen: true,
splashImmersive: true,
},
Keyboard: {
resize: 'body',
resizeOnFullScreen: true,
},
StatusBar: {
style: 'light',
},
},
};
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. Add to
capacitor.config.ts:
import type { CapacitorConfig } from '@capacitor/cli';
const devConfig: CapacitorConfig = {
appId: 'com.example.mymobileapp',
appName: 'My Mobile App',
webDir: 'out',
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: 'out',
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 Next.js code will hot-reload on the device.
Step 9: Create Your First Mobile Screen
Let’s create a simple mobile-friendly home screen. Update src/app/page.tsx:
'use client';
import { useEffect, useState } from 'react';
import { App } from '@capacitor/app';
import { Keyboard } from '@capacitor/keyboard';
export default function Home() {
const [appInfo, setAppInfo] = useState<{ name: string; version: string } | null>(null);
useEffect(() => {
// Get app info on mount
App.getInfo().then(setAppInfo).catch(console.error);
// Handle back button on Android
const backHandler = App.addListener('backButton', ({ canGoBack }) => {
if (!canGoBack) {
App.exitApp();
} else {
window.history.back();
}
});
// Hide keyboard when tapping outside inputs
const keyboardHandler = Keyboard.addListener('keyboardWillShow', () => {
document.body.classList.add('keyboard-open');
});
return () => {
backHandler.then(h => h.remove());
keyboardHandler.then(h => h.remove());
};
}, []);
return (
<main className="min-h-screen bg-linear-to-b from-blue-500 to-blue-700 flex flex-col items-center justify-center p-6 text-white">
<h1 className="text-4xl font-bold mb-4">My Mobile App</h1>
<p className="text-xl mb-8 text-center opacity-90">
Built with Next.js 15 + Capacitor 8
</p>
{appInfo && (
<div className="bg-white/20 rounded-lg p-4 backdrop-blur-sm">
<p className="text-sm">
{appInfo.name} v{appInfo.version}
</p>
</div>
)}
<div className="mt-12 space-y-4 w-full max-w-sm">
<button className="w-full py-4 px-6 bg-white text-blue-600 rounded-xl font-semibold text-lg shadow-lg active:scale-95 transition-transform">
Get Started
</button>
<button className="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">
Learn More
</button>
</div>
</main>
);
}
Step 10: Add Safe Area Handling
Mobile devices have notches, home indicators, and status bars. Add safe area handling with Tailwind.
Update src/app/globals.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
: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;
}
/* Keyboard handling */
.keyboard-open {
--sab: 0px;
}
Project Structure
Your project should now look like this:
my-mobile-app/
├── android/ # Android native project
├── ios/ # iOS native project
├── out/ # Static build output
├── src/
│ ├── app/
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── ...
├── capacitor.config.ts # Capacitor configuration
├── next.config.ts # Next.js configuration
├── package.json
└── ...
Next Steps
You now have a working Next.js 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-notifications - 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 Next.js-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
App Router (app/): export viewport from app/layout.tsx:
import type { Viewport } from 'next';
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
};
Pages Router (pages/): put the viewport meta tag in pages/_app.tsx, not _document.tsx.
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,
#__next {
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: 'out',
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.
Resources
- Capacitor 8 Documentation
- Next.js 15 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 Next.js Mobile App from Scratch with Capacitor 8
If you are using Build a Next.js 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.