| name | nextjs-capacitor |
| description | Project-agnostic guide for setting up Next.js with Capacitor for native mobile support and Ionic React for UI components. Includes core setup, optional enhancements, and complete push notifications implementation. |
Next.js + Capacitor + Ionic React Setup
Purpose
This skill provides a comprehensive, project-agnostic guide for setting up a Next.js application with Capacitor for native mobile support and Ionic React for UI components. It covers core setup requirements, optional enhancements, and complete push notification implementation.
When to Use
Use this skill when:
- Setting up a new Next.js project with Capacitor and Ionic React
- Adding Capacitor to an existing Next.js application
- Configuring push notifications for a Capacitor app
- Troubleshooting Capacitor build or sync issues
- Understanding the conditional static export pattern for Next.js + Capacitor
Architecture Overview
Project Structure Options
You can organize your project in two ways:
Option A: Root-level src/ directory (Single app)
your-project/
├── src/ # Next.js frontend at root
│ └── app/
├── backend/ # Optional backend (if monorepo)
├── capacitor.config.ts
└── package.json
- Capacitor
webDir: "dist"
- Common for single-app projects
Option B: Separate frontend/ directory (Monorepo)
your-project/
├── frontend/ # Next.js frontend
│ └── src/
│ └── app/
├── backend/ # Backend API
├── capacitor.config.ts
└── package.json
- Capacitor
webDir: "frontend/dist"
- Better for projects with separate frontend/backend
Key Concepts
- Conditional Static Export: Next.js only exports statically when
CAPACITOR_BUILD=true, allowing normal Next.js development
- Capacitor Integration: Capacitor wraps the static Next.js build into native iOS/Android apps
- Ionic React: Provides mobile-optimized UI components that work on web and native
Core Setup Instructions
Step 1: Create Next.js Project
npx create-next-app@latest . --typescript --app --tailwind --eslint --src-dir
When prompted:
- Choose TypeScript (recommended)
- Choose App Router (required)
- Choose Tailwind CSS (optional but recommended)
- Choose ESLint (recommended)
Step 2: Install Core Dependencies
npm install @capacitor/core @capacitor/cli @capacitor/android @capacitor/ios
npm install @ionic/react ionicons
npm install @capacitor/splash-screen @capacitor/status-bar @capacitor/app
Step 3: Initialize Capacitor
npx cap init
When prompted:
- App name: YourAppName
- App ID: com.yourcompany.yourapp (use reverse domain notation)
- Web dir:
dist (for root src/) or frontend/dist (for monorepo)
This creates capacitor.config.ts at the root level.
Step 4: Configure Capacitor
Update capacitor.config.ts:
import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.yourcompany.yourapp",
appName: "YourAppName",
webDir: "dist",
plugins: {
SplashScreen: {
launchAutoHide: false,
},
StatusBar: {
style: "DARK",
overlaysWebView: false,
backgroundColor: "#000000",
},
},
};
export default config;
Step 5: Configure Next.js
Update next.config.js:
const nextConfig = {
...(process.env.CAPACITOR_BUILD === "true" && {
output: "export",
images: {
unoptimized: true,
},
trailingSlash: true,
distDir: "dist",
}),
transpilePackages: ["@ionic/react", "@ionic/core", "@stencil/core"],
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
crypto: false,
stream: false,
util: false,
path: false,
os: false,
tls: false,
net: false,
dns: false,
child_process: false,
http: false,
https: false,
zlib: false,
querystring: false,
url: false,
buffer: false,
timers: false,
"timers/promises": false,
diagnostics_channel: false,
};
}
config.module = {
...config.module,
unknownContextCritical: false,
unknownContextRegExp: /^\.\/.*$/,
unknownContextRequest: ".",
};
return config;
},
};
module.exports = nextConfig;
Important: The conditional static export allows normal Next.js development while enabling Capacitor builds when needed.
Step 6: Update TypeScript Config
Update tsconfig.json:
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
"dist/types/**/*.ts"
],
"exclude": ["node_modules"]
}
Step 7: Create Root Layout
Create src/app/layout.tsx:
import type { Metadata } from "next";
import "@ionic/react/css/core.css";
import "@ionic/react/css/normalize.css";
import "@ionic/react/css/structure.css";
import "@ionic/react/css/typography.css";
import "@ionic/react/css/padding.css";
import "@ionic/react/css/float-elements.css";
import "@ionic/react/css/text-alignment.css";
import "@ionic/react/css/text-transformation.css";
import "@ionic/react/css/flex-utils.css";
import "@ionic/react/css/display.css";
import "@ionic/react/css/ionic.bundle.css";
import "@ionic/react/css/palettes/dark.css";
import "./globals.css";
import IonicApp from "./IonicApp";
export const metadata: Metadata = {
title: "YourAppName",
description: "Your app description",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#31d53d" />
</head>
<body className="" style={{ overflow: "hidden" }}>
<IonicApp>{children}</IonicApp>
</body>
</html>
);
}
Step 8: Create Basic IonicApp Component
Create src/app/IonicApp.tsx:
"use client";
import { IonApp, setupIonicReact } from "@ionic/react";
setupIonicReact();
export default function IonicApp({ children }: { children: React.ReactNode }) {
return <IonApp>{children}</IonApp>;
}
Step 9: Create Global Styles
Create src/app/globals.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--ion-color-primary: #31d53d;
--ion-color-primary-rgb: 49, 213, 61;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #2bbb36;
--ion-color-primary-tint: #46d954;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
Step 10: Update Package Scripts
Update package.json scripts:
{
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "next lint",
"cap:sync": "CAPACITOR_BUILD=true next build && cap sync",
"cap:ios": "CAPACITOR_BUILD=true next build && cap sync ios",
"cap:android": "CAPACITOR_BUILD=true next build && cap sync android",
"cap:open:ios": "cap open ios",
"cap:open:android": "cap open android"
}
}
Step 11: Add Native Platforms
npx cap add ios
npx cap sync
npx cap add android
npx cap sync
Optional Enhancements
Enhanced IonicApp Component
You can enhance the basic IonicApp.tsx with additional features:
"use client";
import { useEffect } from "react";
import { IonApp, setupIonicReact } from "@ionic/react";
import { Capacitor } from "@capacitor/core";
import { SplashScreen } from "@capacitor/splash-screen";
import { StatusBar, Style } from "@capacitor/status-bar";
import { App, AppState } from "@capacitor/app";
setupIonicReact();
export default function IonicApp({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (Capacitor.isNativePlatform()) {
document.body.classList.add('native-platform');
} else {
document.body.classList.add('web-platform');
document.documentElement.style.setProperty('--safe-area-inset-top', '44px');
document.documentElement.style.setProperty('--safe-area-inset-bottom', '34px');
}
if (Capacitor.isNativePlatform()) {
try {
StatusBar.setStyle({ style: Style.Dark });
StatusBar.setOverlaysWebView({ overlay: false });
StatusBar.setBackgroundColor({ color: "#000000" });
} catch (error) {
console.warn("Failed to configure status bar:", error);
}
try {
setTimeout(() => {
SplashScreen.hide({ fadeOutDuration: 200 });
}, 100);
} catch (error) {
console.warn("Failed to hide splash screen:", error);
}
const stateListener = App.addListener("appStateChange", (state: AppState) => {
if (state.isActive) {
console.log("App became active");
}
});
return () => {
document.body.classList.remove('native-platform', 'web-platform');
stateListener.remove();
};
}
}, []);
return <IonApp>{children}</IonApp>;
}
Features you can add:
- StatusBar configuration (style, background color, overlay behavior)
- SplashScreen management (auto-hide with fade animations)
- Platform detection (different behavior for native vs web)
- Safe area handling (CSS classes for platform targeting)
- App lifecycle listeners (handle app state changes)
Enhanced Global Styles with Safe Areas
You can add safe area handling to globals.css:
html {
overscroll-behavior: none;
}
:root {
--ion-color-primary: #31d53d;
}
body.native-platform {
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
}
body.web-platform {
--safe-area-inset-top: 44px;
--safe-area-inset-bottom: 34px;
--safe-area-inset-left: 0px;
--safe-area-inset-right: 0px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overscroll-behavior: none;
}
ion-toolbar {
padding-top: var(--safe-area-inset-top) !important;
min-height: calc(56px + var(--safe-area-inset-top)) !important;
}
body.web-platform::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--safe-area-inset-top);
background: rgba(255, 0, 0, 0.3);
z-index: 9999;
pointer-events: none;
}
body.web-platform::after {
content: '';
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--safe-area-inset-bottom);
background: rgba(0, 255, 0, 0.3);
z-index: 9999;
pointer-events: none;
}
Additional Capacitor Plugins
You may want to add these optional plugins:
npm install @capacitor/camera @capacitor/device @capacitor/haptics @capacitor/browser
@capacitor/camera - Camera access
@capacitor/device - Device information
@capacitor/haptics - Haptic feedback
@capacitor/browser - In-app browser functionality
Push Notifications Setup
Step 1: Install Push Notifications Plugin
npm install @capacitor/push-notifications
Step 2: Configure Capacitor
Update capacitor.config.ts to include push notification configuration:
import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.yourcompany.yourapp",
appName: "YourAppName",
webDir: "dist",
plugins: {
PushNotifications: {
presentationOptions: ["badge", "sound", "alert"],
},
},
};
export default config;
Step 3: iOS Setup
3.1: Enable Push Notifications Capability
- Open your iOS project:
npx cap open ios
- In Xcode, select your project in the navigator
- Select your app target
- Go to "Signing & Capabilities"
- Click "+ Capability" and add "Push Notifications"
3.2: Configure APNs (Apple Push Notification service)
- Go to Apple Developer Portal
- Navigate to "Certificates, Identifiers & Profiles"
- Create an APNs Key or Certificate:
- APNs Key (recommended): Create a new key with "Apple Push Notifications service (APNs)" enabled
- Download the
.p8 key file (you can only download once!)
- Note the Key ID and Team ID
- Or create an APNs Certificate:
- Create a new certificate for "Apple Push Notifications service (APNs)"
- Download and install the certificate
3.3: Update Info.plist (if needed)
Usually not required, but you can add:
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
Step 4: Android Setup
4.1: Set Up Firebase Cloud Messaging (FCM)
- Go to Firebase Console
- Create a new project or select existing
- Add Android app to your project:
- Package name:
com.yourcompany.yourapp (must match your app ID)
- Download
google-services.json
- Place
google-services.json in android/app/
4.2: Update Android Build Files
Update android/build.gradle:
buildscript {
dependencies {
// Add Google Services classpath
classpath 'com.google.gms:google-services:4.4.0'
}
}
Update android/app/build.gradle:
apply plugin: 'com.android.application'
apply plugin: 'com.google.gms.google-services' // Add this line
android {
// ... your config
}
Update android/settings.gradle:
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
4.3: Sync Android Project
npx cap sync android
Step 5: Create Push Notifications Hook
Create src/app/hooks/usePushNotifications.ts:
import { useState, useCallback } from "react";
import { PushNotifications } from "@capacitor/push-notifications";
import { Capacitor } from "@capacitor/core";
export function usePushNotifications() {
const [pushEnabled, setPushEnabled] = useState<boolean | null>(null);
const [pushError, setPushError] = useState<string | null>(null);
const [pushStatus, setPushStatus] = useState("Enable Push Notifications");
const [pushLoading, setPushLoading] = useState(false);
const subscribeToPush = useCallback(async () => {
setPushLoading(true);
setPushStatus("Subscribing…");
setPushError(null);
try {
if (!Capacitor.isNativePlatform()) {
setPushError("Push notifications are only available on mobile devices.");
setPushStatus("Enable Push Notifications");
return;
}
const permission = await PushNotifications.requestPermissions();
if (permission.receive === "denied") {
setPushError("Notification permission denied.");
setPushStatus("Enable Push Notifications");
return;
}
const registrationListener = await PushNotifications.addListener(
"registration",
async (token) => {
if (!token || !token.value) {
setPushError("Received invalid registration token.");
setPushStatus("Enable Push Notifications");
return;
}
try {
const response = await fetch("https://your-api.com/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: token.value }),
});
if (response.ok) {
setPushEnabled(true);
setPushStatus("Push notifications enabled!");
} else {
setPushError("Failed to save notification token.");
setPushStatus("Enable Push Notifications");
}
} catch (err) {
setPushError("Failed to send token to server.");
setPushStatus("Enable Push Notifications");
}
}
);
const registrationErrorListener = await PushNotifications.addListener(
"registrationError",
(error) => {
const errorMessage = (error as any)?.error || String(error) || "Unknown error";
setPushError(`Failed to register: ${errorMessage}`);
setPushStatus("Enable Push Notifications");
}
);
const receivedListener = await PushNotifications.addListener(
"pushNotificationReceived",
(notification) => {
console.log("Push notification received:", notification);
}
);
const actionListener = await PushNotifications.addListener(
"pushNotificationActionPerformed",
(notification) => {
console.log("Push notification action:", notification);
}
);
try {
await PushNotifications.createChannel({
id: "default",
name: "Default Channel",
description: "General notifications",
importance: 5,
visibility: 1,
lights: true,
vibration: true,
});
} catch (channelError) {
console.warn("Failed to create notification channel:", channelError);
}
await PushNotifications.register();
} catch (err) {
const errorMessage = (err as Error).message.toLowerCase();
let userFriendlyError = "Failed to enable push notifications.";
if (errorMessage.includes("simulator") || errorMessage.includes("emulator")) {
userFriendlyError = "Push notifications are not available in the simulator. Please test on a physical device.";
} else if (errorMessage.includes("permission") || errorMessage.includes("denied")) {
userFriendlyError = "Notification permission was denied. Please enable notifications in your device settings.";
}
setPushError(userFriendlyError);
setPushStatus("Enable Push Notifications");
} finally {
setPushLoading(false);
}
}, []);
const unsubscribeFromPush = async () => {
setPushLoading(true);
setPushError(null);
setPushStatus("Unsubscribing…");
try {
const response = await fetch("https://your-api.com/api/push/unsubscribe", {
method: "POST",
});
if (response.ok) {
setPushEnabled(false);
setPushStatus("Enable Push Notifications");
} else {
setPushError("Failed to unsubscribe.");
setPushStatus("Disable Push Notifications");
}
} catch (err) {
setPushError("Failed to unsubscribe from push notifications.");
setPushStatus("Disable Push Notifications");
} finally {
setPushLoading(false);
}
};
return {
pushEnabled,
pushError,
pushStatus,
pushLoading,
subscribeToPush,
unsubscribeFromPush,
};
}
Step 6: Use Push Notifications in Components
"use client";
import { usePushNotifications } from "@/app/hooks/usePushNotifications";
import { IonButton } from "@ionic/react";
export default function SettingsPage() {
const {
pushEnabled,
pushError,
pushStatus,
pushLoading,
subscribeToPush,
unsubscribeFromPush,
} = usePushNotifications();
return (
<div>
<h1>Push Notifications</h1>
{pushError && <p style={{ color: "red" }}>{pushError}</p>}
<IonButton
onClick={pushEnabled ? unsubscribeFromPush : subscribeToPush}
disabled={pushLoading}
>
{pushStatus}
</IonButton>
</div>
);
}
Step 7: Backend Integration
Your backend needs to:
- Store push tokens when users subscribe
- Send push notifications using:
- iOS: APNs (using your APNs key/certificate)
- Android: FCM (using Firebase Admin SDK)
Example backend endpoint (Node.js):
app.post("/api/push/subscribe", async (req, res) => {
const { token, userId } = req.body;
await db.pushTokens.create({ userId, token, platform: "ios" });
res.json({ success: true });
});
import admin from "firebase-admin";
app.post("/api/push/send", async (req, res) => {
const { userId, title, body } = req.body;
const tokens = await db.pushTokens.findAll({ where: { userId } });
const messages = tokens.map(token => ({
token: token.token,
notification: { title, body },
}));
await admin.messaging().sendAll(messages);
res.json({ success: true });
});
Step 8: Badge Management (Optional)
You can clear notification badges when the app becomes active:
import { App, AppState } from "@capacitor/app";
import { PushNotifications } from "@capacitor/push-notifications";
useEffect(() => {
if (Capacitor.isNativePlatform()) {
const clearBadge = async () => {
try {
await PushNotifications.removeAllDeliveredNotifications();
} catch (error) {
console.warn("Failed to clear badge:", error);
}
};
clearBadge();
const stateListener = App.addListener("appStateChange", (state: AppState) => {
if (state.isActive) {
clearBadge();
}
});
return () => {
stateListener.remove();
};
}
}, []);
Development Workflow
Running Development Server
npm run dev
This runs Next.js normally (not static export) for development.
Building for Capacitor
npm run cap:sync
npm run cap:ios
npm run cap:android
Opening Native Projects
npm run cap:open:ios
npm run cap:open:android
Testing on Devices
- iOS: Connect device, select it in Xcode, click Run
- Android: Connect device, enable USB debugging, click Run in Android Studio
Important: Push notifications only work on physical devices, not simulators/emulators.
Common Patterns
Client Components
Always use "use client" for components that:
- Use Capacitor APIs
- Use Ionic components that access
window
- Use browser-only APIs
- Handle user interactions
"use client";
import { Capacitor } from "@capacitor/core";
export default function MyComponent() {
}
SSR-Safe Patterns
Check for client-side mounting before accessing window:
"use client";
import { useState, useEffect } from "react";
export default function MyComponent() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return null;
}
return <div>{window.location.pathname}</div>;
}
Platform Detection
import { Capacitor } from "@capacitor/core";
if (Capacitor.isNativePlatform()) {
} else {
}
const platform = Capacitor.getPlatform();
API Client Configuration
For Capacitor apps, use absolute URLs:
import { Capacitor } from "@capacitor/core";
export function getApiBaseUrl(): string {
if (process.env.NEXT_PUBLIC_API_URL) {
return process.env.NEXT_PUBLIC_API_URL;
}
if (Capacitor.isNativePlatform()) {
return process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://your-api.com";
}
return process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://your-api.com";
}
Troubleshooting
Build Errors
Issue: "window is not defined"
- Solution: Ensure components using
window are client components ("use client") and check for mounting
Issue: Capacitor build fails
- Solution:
- Ensure
CAPACITOR_BUILD=true is set during build
- Check that all Node.js polyfills are included in
next.config.js
- Verify
dist directory exists and contains built files
Issue: TypeScript errors with Capacitor
- Solution: Ensure
strict: false in tsconfig.json and ignoreBuildErrors: true in next.config.js
Capacitor Sync Issues
Issue: Sync fails with "webDir not found"
- Solution:
- Run
CAPACITOR_BUILD=true npm run build first
- Verify
capacitor.config.ts webDir matches next.config.js distDir
Issue: Native dependencies not updating
- Solution:
- Delete
ios/Pods and android/.gradle
- Run
npx cap sync again
Push Notification Issues
Issue: Push notifications not working on iOS
- Solution:
- Verify APNs key/certificate is configured correctly
- Check that Push Notifications capability is enabled in Xcode
- Ensure testing on physical device (not simulator)
- Check that token is being sent to backend correctly
Issue: Push notifications not working on Android
- Solution:
- Verify
google-services.json is in android/app/
- Check that Firebase project is configured correctly
- Ensure testing on physical device (not emulator)
- Verify notification channel is created (Android 8.0+)
Issue: "Registration event did not fire"
- Solution:
- This often indicates APNs configuration issue on iOS
- Verify APNs key/certificate is valid
- Check that app is properly signed with correct provisioning profile
Additional Resources