The Perfect Guide to Transforming a NextJS App into a Native App

Geekits is an open-source project I have been developing for over five years and is the one I have maintained the longest. After continuous iterations, the Next technology stack has stabilized.

This article will introduce how I transformed a pure Web App into an elegant Native App, enabling the product to run on multiple platforms.

The transformation involves more than just placing the App in a WebView; it requires removing unnecessary features for the native app, replacing certain JS implementations with native APIs, adapting styles, and more.

Why Create a Native App

I personally do not fully agree with the statement that "the web is the future." However, I really like local-first web apps, so even when developing the Web App, I made every effort to make Geekits available offline.

Migrating to native makes the implementation of local-first more convenient, resources load faster, and large data does not need to be loaded online (for example, the idiom query feature).

Moreover, the operable window size is larger, eliminating the worry of accidentally closing tabs, and the response speed is faster.

Finally, the system provides more information that is inconvenient for browsers to access (or version-restricted), such as accelerometer data, battery information, etc.

Step 1 - Install and Configure Capacitor

Thanks to Capacitor's excellent developer experience (DX), if all goes well, you can get a "working" native version in just a few minutes.

First, install Capacitor:

1npm i @capacitor/core
2npm i -D @capacitor/cli
3npx cap init
4npm i @capacitor/android @capacitor/ios

Modify the configuration file capacitor.config.js:

1import { CapacitorConfig } from "@capacitor/cli";
2
3const config: CapacitorConfig = {
4	appId: "com.ygeeker.geekits",
5	appName: "Geekits",
6	webDir: "out",
7	server: {
8		androidScheme: "https",
9		errorPath: "/500.html",
10	},
11};
12
13export default config;

If your Next APP has always been a pure static export, congratulations, you are done. Run this command to compile your Next App into static files:

1next export

Then sync the compiled code to Capacitor:

1npx cap sync

Done!

However, if your Next App uses a hybrid build mode (for example, utilizing server-side features), you may not be able to execute next export directly. You need to move some server-dependent logic to the client side. For example:

  • The client uses the device API to get the locale (instead of Next's Locale API)
  • The client uses hooks to fetch data (instead of using methods like getServerProps)

Here is an example of getting the Locale from the client:

1async function getDeviceLanguage() {
2	let { value } = await Device.getLanguageCode();
3
4	if (value === "en") {
5		value = "en-US";
6	}
7	if (value === "zh") {
8		value = "zh";
9	}
10
11	return value;
12}
13
14function MainApp({ Component, pageProps }: AppProps) {
15	// The auto-detected locale from NextJS
16	// If the user has no preferred locale, use auto
17	const [preferredLocale, setPreferredLocale] = useState(pageProps.locale);
18
19	useEffect(() => {
20		const readLocaleConfig = async () => {
21			// https://en.wikipedia.org/wiki/IETF_language_tag
22			let preferredSet = localStorage.getItem("locale");
23			if (preferredSet) {
24				if (preferredSet === "auto" && !isWeb()) {
25					setPreferredLocale(await getDeviceLanguage());
26				} else if (preferredSet !== "auto") {
27					// If the user has specified the language
28					setPreferredLocale(preferredSet);
29				}
30			}
31		};
32
33		readLocaleConfig();
34	}, []);
35	
36}

Step 2 - Add Safe Area Insets

At this point, we have a basic native App, but there are still many small issues that need to be adapted.

Unlike the browser environment, the irregular screens of mobile devices can affect display, such as notches and holes. To avoid bleeding, you can add the following global variables in the global CSS file:

1html {
2	--ion-safe-area-top: env(safe-area-inset-top);
3	--ion-safe-area-bottom: env(safe-area-inset-bottom);
4	--ion-safe-area-left: env(safe-area-inset-left);
5	--ion-safe-area-right: env(safe-area-inset-right);
6}

Then use this property in places where you need to reserve the Bleeding Area, such as the Appbar (header):

1padding-top: var(--ion-safe-area-top),

Step 3 - Runtime Condition Handling

Although Capacitor provides APIs to detect the running platform, during the static export process of Next, its API will always detect as a browser environment.

For example, sometimes we want certain React components to appear only on the native platform, and during export, we need to inform Next whether we are packaging for the native platform.

To detect the specified packaging platform during the Next export process, we can manually add an environment variable to specify the export target. For convenience, you can write the following scripts in package.json:

1"scripts": {
2		"build": "next build",
3		"build:cap": "CAPACITOR_BUILD=true next build && CAPACITOR_BUILD=true next export && npx cap sync",
4		"dev:cap": "CAPACITOR_BUILD=true next",
5 },

Next, encapsulate a method to detect the packaging platform:

1const isCapacitor = () => process.env.CAPACITOR_BUILD === "true";

Step 4 - Add Native Icons

Capacitor does not generate various sizes of native App icons for you; you need to create and replace them manually.

You only need to prepare a 1024 x 1024 icon image, and then you can use this tool to generate icons for iOS and Android platforms with one click.

Simply replace the downloaded files in your project.

P.S. This tool cannot generate Adaptive Icons, so you will need to create the foreground and background yourself.

Next Steps

The capabilities of Capacitor go far beyond what is written in this article; you can explore more possibilities from the official documentation.