Progressive Web Applications (PWA)
Progressive Web Applications(PWA)は、Webアプリケーションの到達範囲とアクセシビリティを、ネイティブモバイルアプリの機能とユーザーエクスペリエンスと組み合わせたものです。Next.jsを使用すると、複数のコードベースやアプリストアの承認を必要とせず、すべてのプラットフォームでシームレスでアプリのようなエクスペリエンスを提供するPWAを作成できます。
PWAを使用することで:
- アプリストアの承認を待たずに、即時にアップデートをデプロイ
- 単一のコードベースでクロスプラットフォームのアプリケーションを作成
- ホーム画面へのインストールやプッ シュ通知などのネイティブのような機能を提供
Next.jsを使用したPWAの作成
1. Web App Manifestの作成
Next.jsは、app routerを使用してweb app manifestを作成するための組み込みサポートを提供しています。静的または動的なマニフェストファイルのいずれかを作成できます:
たとえば、app/manifest.ts
またはapp/manifest.json
ファイルを作成します:
- TypeScript
- JavaScript
import type { MetadataRoute } from 'next'
// PWAマニフェストを返す関数
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Next.js PWA',
short_name: 'NextPWA',
description: 'Next.jsで構築されたプログレッシブWebアプリ',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
}
}
// PWAマニフェストを返す関数
export default function manifest() {
return {
name: 'Next.js PWA',
short_name: 'NextPWA',
description: 'Next.jsで構築されたプログレッシブWebアプリ',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
}
}
このファイルには、名前、アイコン、ユーザーのデバイスにアイコンとしてどのように表示されるべきかの情報を含める必要があります。これにより、ユーザーはホーム画面にPWAをインストールすることができ、ネイティブアプリのような体験を提供します。
favicon ジェネレーターのようなツールを使用して、さまざまなアイコンセットを作成し、生成されたファイルをpublic/
フォルダーに配置することができます。
2. Web Push通知の実装
Web Push通知は、以下を含むすべての最新ブラウザでサポートされています:
- ホームスクリーンにインストールされたアプリケーションに対するiOS 16.4以降
- macOS 13以降のSafari 16
- Chromiumベースのブラウザ
- Firefox
これにより、PWAはネイティブアプリの代替として現実的な選択肢となります。オフラインサポートがなくてもインストールプロンプトをトリガーすることができる点が特徴です。
Web Push通知は、ユーザーがアプリを積極的に使用していなくても、再びエンゲージすることを可能にします。Next.jsアプリケーションでそれらを実装する方法を示します:
まず、app/page.tsx
にメインページコンポーネントを作成します。理解を深めるために、それを小さな部分に分解します。最初に必要なインポートとユーティリティを追加します。引用されているServer Actionsはまだ存在しなくても問題ありません:
'use client'
import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser, sendNotification } from './actions'
// base64文字列をUint8Arrayに変換する関数
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding)
.replace(/\\-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
'use client'
import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser, sendNotification } from './actions'
// base64文字列をUint8Arrayに変換する関数
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding)
.replace(/\\-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
次に、購読、購読解除、プッシュ通知の送信を管理するコンポーネントを追加します。
function PushNotificationManager() {
const [isSupported, setIsSupported] = useState(false)
const [subscription, setSubscription] = useState<PushSubscription | null>(
null
)
const [message, setMessage] = useState('')
useEffect(() => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true)
registerServiceWorker()
}
}, [])
async function registerServiceWorker() {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
})
const sub = await registration.pushManager.getSubscription()
setSubscription(sub)
}
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
),
})
setSubscription(sub)
await subscribeUser(sub)
}
async function unsubscribeFromPush() {
await subscription?.unsubscribe()
setSubscription(null)
await unsubscribeUser()
}
async function sendTestNotification() {
if (subscription) {
await sendNotification(message)
setMessage('')
}
}
if (!isSupported) {
return <p>このブラウザではプッシュ通知はサポートされていません。</p>
}
return (
<div>
<h3>Push Notifications</h3>
{subscription ? (
<>
<p>プッシュ通知に購読しています。</p>
<button onClick={unsubscribeFromPush}>購読解除</button>
<input
type="text"
placeholder="通知メッセージを入力"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button onClick={sendTestNotification}>テスト通知を送信</button>
</>
) : (
<>
<p>プッシュ通知に購読していません。</p>
<button onClick={subscribeToPush}>購読</button>
</>
)}
</div>
)
}
function PushNotificationManager() {
const [isSupported, setIsSupported] = useState(false);
const [subscription, setSubscription] = useState(null);
const [message, setMessage] = useState('');
useEffect(() => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true);
registerServiceWorker();
}
}, []);
async function registerServiceWorker() {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
});
const sub = await registration.pushManager.getSubscription();
setSubscription(sub);
}
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
),
});
setSubscription(sub);
await subscribeUser(sub);
}
async function unsubscribeFromPush() {
await subscription?.unsubscribe();
setSubscription(null);
await unsubscribeUser();
}
async function sendTestNotification() {
if (subscription) {
await sendNotification(message);
setMessage('');
}
}
if (!isSupported) {
return <p>このブラウザではプッシュ通知はサポートされていません。</p>;
}
return (
<div>
<h3>Push Notifications</h3>
{subscription ? (
<>
<p>プッシュ通知に購読しています。</p>
<button onClick={unsubscribeFromPush}>購読解除</button>
<input
type="text"
placeholder="通知メッセージを入力"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button onClick={sendTestNotification}>テスト通知を送信</button>
</>
) : (
<>
<p>プッシュ通知に購読していません。</p>
<button onClick={subscribeToPush}>購読</button>
</>
)}
</div>
);
}
最後に、iOSデバイス向けにホーム画面に追加するためのメッセージを表示するコンポーネントを作成します。このメッセージは、アプリがすでにインストールされている場合には表示されないようにします。
function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false)
const [isStandalone, setIsStandalone] = useState(false)
useEffect(() => {
setIsIOS(
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
)
setIsStandalone(window.matchMedia('(display-mode: standalone)').matches)
}, [])
if (isStandalone) {
return null // すでにインストールされている場合はインストールボタンを表示しない
}
return (
<div>
<h3>アプリをインストール</h3>
<button>ホーム画面に追加</button>
{isIOS && (
<p>
このアプリをiOSデバイスにインストールするには、共有ボタン
<span role="img" aria-label="share icon">
{' '}
⎋{' '}
</span>
をタップし、「ホーム画面に追加」
<span role="img" aria-label="plus icon">
{' '}
➕{' '}
</span>
を選択してください。
</p>
)}
</div>
)
}
export default function Page() {
return (
<div>
<PushNotificationManager />
<InstallPrompt />
</div>
)
}
function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
useEffect(() => {
setIsIOS(
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
);
setIsStandalone(window.matchMedia('(display-mode: standalone)').matches);
}, []);
if (isStandalone) {
return null; // すでにインストールされている場合はインストールボタンを表示しない
}
return (
<div>
<h3>アプリをインストール</h3>
<button>ホーム画面に追加</button>
{isIOS && (
<p>
このアプリをiOSデバイスにインストールするには、共有ボタン
<span role="img" aria-label="share icon">
{' '}
⎋{' '}
</span>
をタップし、「ホーム画面に追加」
<span role="img" aria-label="plus icon">
{' '}
➕{' '}
</span>
を選択してください。
</p>
)}
</div>
);
}
export default function Page() {
return (
<div>
<PushNotificationManager />
<InstallPrompt />
</div>
);
}