2025 . 오은

repo

pwa

1. next-pwa 설치

nextjs 14버전 입니다.

yarn add next-pwa 
yarn add -D webpack

2. next.config.mjs 수정

아래와 같이 수정합니다.

import withPWAInit from "next-pwa";

const withPWA = withPWAInit({
	dest: "public",
});

/** @type {import('next').NextConfig} */
const nextConfig = {};

export default withPWA(nextConfig);

3. 퍼블릭에 manifest.json

/public 폴더에 아래와 같이 manifest.json 파일을 작성합니다.

{
	"name": "My Next.js PWA",
	"short_name": "NextPWA",
	"description": "My awesome Next.js PWA!",
	"icons": [
		{
			"src": "/test_icon.png",
			"type": "image/png",
			"sizes": "192x192"
		},
		{
			"src": "/test_icon.png",
			"type": "image/png",
			"sizes": "512x512"
		}
	],
	"start_url": "/",
	"background_color": "#ffffff",
	"theme_color": "#000000",
	"display": "standalone"
}

4. layout.tsx

루트 layout.tsx 에 아래와 같이 viewport 와 metadata 를 설정해줍니다.

export const viewport: Viewport = {
	themeColor: "black",
	width: "device-width",
	initialScale: 1,
	maximumScale: 1,
	userScalable: false,
	viewportFit: "cover",
};

export const metadata: Metadata = {
	title: "Create Next App",
	description: "Generated by create next app",
	manifest: "/manifest.json",
	icons: {
		icon: "/test_icon.png",
		shortcut: "/test_icon.png",
		apple: "/test_icon.png",
		other: {
			rel: "apple-touch-icon-precomposed",
			url: "/test_icon.png",
		},
	},
};

5. 설치 유도

public/sw.js

설치 유도를 하려면, service worker와 BeforeInstallPromptEvent를 사용해야 합니다.

https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent

먼저, public/sw.js 파일을 작성합니다.

// public/sw.js
import { clientsClaim } from 'workbox-core';
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

clientsClaim();

// self.__WB_MANIFEST is injected by workbox-build during the build process
precacheAndRoute(self.__WB_MANIFEST || []);

// Cache CSS, JS, and web worker requests with a network-first strategy.
registerRoute(
  ({ request }) => request.destination === 'style' || request.destination === 'script' || request.destination === 'worker',
  new NetworkFirst({
    cacheName: 'static-resources',
  })
);

// Cache image files with a cache-first strategy.
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
      }),
    ],
  })
);

// Cache API calls with a network-first strategy.
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api',
    networkTimeoutSeconds: 10,
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
    ],
  })
);

// Cache the start URL with a network-first strategy.
registerRoute(
  '/',
  new NetworkFirst({
    cacheName: 'start-url',
    plugins: [
      {
        cacheWillUpdate: async ({ request, response }) => {
          if (response && response.type === 'opaqueredirect') {
            return new Response(response.body, {
              status: 200,
              statusText: 'OK',
              headers: response.headers,
            });
          }
          return response;
        },
      },
    ],
  })
);

// Cache everything else with a network-only strategy.
registerRoute(
  ({ request }) => true,
  new CacheFirst({
    cacheName: 'catch-all',
  })
);

utils/isPWA.ts

export const isPWA = (): boolean => {
	return (
		window.matchMedia("(display-mode: standalone)").matches ||
			(window.navigator as any).standalone === true
	);
};

위 코드는 주소창 존재 여부를 판별해 줍니다. 그리하여 현재 앱이 pwa 모드로 작동되고있는지를 판별할 수 있습니다.

응용.useCheckPwa

유틸함수를 응용하여 아래처럼 훅을 만들 수 있을 것 같습니다.

import { useEffect, useState } from 'react';

const useCheckPwa = (): boolean => {
	const [isPwa, setIsPwa] = useState(false);
	
	useEffect(() => {
		const checkPwa = (): boolean => {
		return window.matchMedia('(display-mode: standalone)').matches 
		|| (window.navigator as any).standalone === true;
	};
	
	setIsPwa(checkPwa());
	}, []);
	
	return isPwa;
};

export default useCheckPwa;

버튼 컴포넌트

실험결과, 자동으로 판단해서 설치프롬프트를 띄워줄 수는 없습니다. 특히 모바일에서 사용자 상호작용이 없이는 안되더라고요.

그래서 아래처럼 버튼 컴포넌트로 만들 수 있습니다.

"use client";
  
import useCheckPwa from '@/hooks/useCheckPwa';
import { useEffect, useState } from 'react';

  
const InstallPromptHandler = () => {
	const [deferredPrompt, setDeferredPrompt] = useState<Event | null>(null);
	const isPwa = useCheckPwa();

	useEffect(() => {
		const handler = (e: Event) => {
			e.preventDefault();
			setDeferredPrompt(e);
		};
  
		window.addEventListener('beforeinstallprompt', handler as any);
		
		return () => {
			window.removeEventListener('beforeinstallprompt', handler as any);
		};
	}, []);

  
	const handleInstallClick = () => {
		if (deferredPrompt) {
			(deferredPrompt as any).prompt();
			(deferredPrompt as any).userChoice.then((choiceResult: any) => {
				if (choiceResult.outcome === 'accepted') {
					console.log('User accepted the install prompt');
				} else {
					console.log('User dismissed the install prompt');
				}
				setDeferredPrompt(null);
			});
		}
	};

  
	if (isPwa) {
		return null;
	}

  
	if (!isPwa) {
		return (
			<button
				onClick={handleInstallClick}
				className="bg-blue-500 text-white px-4 py-2 rounded-md"
			>			
			홈 화면에 추가하기
			</button>
		</>
		)
	}
};

export default InstallPromptHandler;

추가. ios

ios 는 현재 BeforeInstallPromptEvent가 지원되지 않습니다! 어서 지원되기를...