Progressive Web Applications (PWA)
Progressive Web Applications (PWA)は、Webアプリケーションの到達範囲とアクセシビリティを、ネイティブモバイルアプリの機能とユーザーエクスペリエンスと組み合わせたものです。Next.jsを使用すると、複数のコードベースやアプリストアの承認を必要とせずに、すべてのプラットフォームでシームレスでアプリのような体験を提供するPWAを作成できます。
PWAを使用すると、次のことが可能です:
- アプリストアの承認を待たずに、即座に更新をデプロイする
- 単一のコードベースでクロスプラットフォームのアプリケーションを作成する
- ホーム画面へのインストールやプッシュ通知など、ネイティブのような機能を提供する
Next.jsでPWAを作成する
1. Webアプリマニフェストの作成
Next.jsは、App Routerを使用してwebアプリマニフェストを作成するための組み込みサポートを提供しています。静的または動的なマニフェストファイルを作成できます:
例えば、app/manifest.ts
またはapp/manifest.json
ファイルを作成します:
- TypeScript
- JavaScript
import type { MetadataRoute } from 'next'
// マニフェストを返す関数
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Next.js PWA',
short_name: 'NextPWA',
description: 'Next.jsで構築されたProgressive Web App',
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',
},
],
}
}
// マニフェストを返す関数
export default function manifest() {
return {
name: 'Next.js PWA',
short_name: 'NextPWA',
description: 'Next.jsで構築されたProgressive Web App',
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プッシュ通知の実装
Webプッシュ通知は、次のすべての最新ブラウザでサポートされています:
- ホーム画面にインストールされたアプリケーション用のiOS 16.4+
- macOS 13以降のSafari 16
- Chromiumベースのブラウザ
- Firefox
これにより、PWAはネイティブアプリの代替として有効です。特に、オフラインサポートを必要とせずにインストールプロンプトをトリガーできます。
Webプッシュ通知を使用すると、ユーザーがアプリを積極的に使用していないときでも再エンゲージできます。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)
const serializedSub = JSON.parse(JSON.stringify(sub))
await subscribeUser(serializedSub)
}
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>プッシュ通知</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>プッシュ通知</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>
);
}
次に、このファイルが呼び出すServer Actionsを作成します。
3. Server Actionsの実装
app/actions.ts
にアクションを含む新しいファイルを作成します。このファイルは、購読の作成、購読の削除、および通知の送信を処理します。
- TypeScript
- JavaScript
'use server'
import webpush from 'web-push'
// VAPIDの詳細を設定
webpush.setVapidDetails(
'<mailto:your-email@example.com>',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
let subscription: PushSubscription | null = null
// ユーザーを購読する関数
export async function subscribeUser(sub: PushSubscription) {
subscription = sub
// 本番環境では、購読をデータベースに保存することをお勧めします
// 例:await db.subscriptions.create({ data: sub })
return { success: true }
}
// ユーザーの購読を解除する関数
export async function unsubscribeUser() {
subscription = null
// 本番環境では、データベースから購読を削除することをお勧めします
// 例:await db.subscriptions.delete({ where: { ... } })
return { success: true }
}
// 通知を送信する関数
export async function sendNotification(message: string) {
if (!subscription) {
throw new Error('利用可能な購読がありません')
}
try {
await webpush.sendNotification(
subscription,
JSON.stringify({
title: 'テスト通知',
body: message,
icon: '/icon.png',
})
)
return { success: true }
} catch (error) {
console.error('プッシュ通知の送信エラー:', error)
return { success: false, error: '通知の送信に失敗しました' }
}
}
'use server';
import webpush from 'web-push';
// VAPIDの詳細を設定
webpush.setVapidDetails(
'<mailto:your-email@example.com>',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
let subscription= null;
// ユーザーを購読する関数
export async function subscribeUser(sub) {
subscription = sub;
// 本番環境では、購読をデータベースに保存することをお勧めします
// 例:await db.subscriptions.create({ data: sub })
return { success: true };
}
// ユーザーの購読を解除する関数
export async function unsubscribeUser() {
subscription = null;
// 本番環境では、データベースから購読を削除することをお勧めします
// 例:await db.subscriptions.delete({ where: { ... } })
return { success: true };
}
// 通知を送信する関数
export async function sendNotification(message) {
if (!subscription) {
throw new Error('利用可能な購読がありません');
}
try {
await webpush.sendNotification(
subscription,
JSON.stringify({
title: 'テスト通知',
body: message,
icon: '/icon.png',
})
);
return { success: true };
} catch (error) {
console.error('プッシュ通知の送信エラー:', error);
return { success: false, error: '通知の送信に失敗しました' };
}
}
通知の送信は、ステップ5で作成したサービスワーカーによって処理されます。
本番環境では、サーバーの再起動時や複数のユーザーの購読を管理するために、購読をデータベースに保存することをお勧めします。
4. VAPIDキーの生成
Web Push APIを使用するには、VAPIDキーを生成する必要があります。最も簡単な方法は、web-push CLIを直接使用することです:
まず、web-pushをグローバルにインストールします:
npm install -g web-push
次に、VAPIDキーを生成します:
web-push generate-vapid-keys
出力をコピーして、.env
ファイルにキーを貼り付けます:
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
5. サービスワーカーの作成
サービスワーカー用にpublic/sw.js
ファイルを作成します:
// プッシュイベントのリスナー
self.addEventListener('push', function (event) {
if (event.data) {
const data = event.data.json()
const options = {
body: data.body,
icon: data.icon || '/icon.png',
badge: '/badge.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2',
},
}
event.waitUntil(self.registration.showNotification(data.title, options))
}
})
// 通知クリックイベントのリスナー
self.addEventListener('notificationclick', function (event) {
console.log('通知クリックを受信しました。')
event.notification.close()
event.waitUntil(clients.openWindow('<https://your-website.com>'))
})
このサービスワーカーは、カスタム画像と通知をサポートします。受信したプッシュイベントと通知クリックを処理します。
- 通知のカスタムアイコンを
icon
とbadge
プロパティを使用して設定できます。 vibrate
パターンを調整して、サポートされているデバイスでカスタム振動アラートを作成できます。data
プロパティを使用して、通知に追加のデータを添付できます。
サービスワーカーが異なるデバイスやブラウザで期待どおりに動作することを確認するために、十分にテストしてください。また、notificationclick
イベントリスナー内の'https://your-website.com'
リンクをアプリケーションの適切なURLに更新することを忘れないでください。
6. ホーム画面への追加
ステップ2で定義したInstallPrompt
コンポーネントは、iOSデバイスにホーム画面にインストールするよう指示するメッセージを表示します。
モバイルのホーム画面にアプリケーションをインストールできるようにするには、次の要件を満たす必要があります:
- 有効なwebアプリマニフェスト(ステップ1で作成)
- HTTPSで提供されるウェブサイト
これらの条件が満たされると、最新のブラウザは自動的にユーザーにインストールプロンプトを表示します。beforeinstallprompt
を使用してカスタムインストールボタンを提供することもできますが、これはクロスブラウザおよびプラットフォームではないため(Safari iOSでは動作しません)、お勧めしません。
7. ローカルでのテスト
ローカルで通知を表示できるようにするために、次のことを確認してください:
- HTTPSでローカルで実行している
- テストには
next dev --experimental-https
を使用
- テストには
- ブラウザ(Chrome、Safari、Firefox)で通知が有効になっている
- ローカルでプロンプトが表示されたら、通知の使用を許可
- ブラウザ全体で通知が無効になっていないことを確認
- 通知が表示されない場合は、別のブラウザを使用してデバッグを試みる
8. アプリケーションのセキュリティ
セキュリティは、特にPWAにおいて、Webアプリケーションの重要な側面です。Next.jsでは、next.config.js
ファイルを使用してセキュリティヘッダーを設定できます。例えば:
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
],
},
{
source: '/sw.js',
headers: [
{
key: 'Content-Type',
value: 'application/javascript; charset=utf-8',
},
{
key: 'Cache-Control',
value: 'no-cache, no-store, must-revalidate',
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self'",
},
],
},
]
},
}
これらのオプションについて説明します:
- グローバルヘッダー(すべてのルートに適用):
X-Content-Type-Options: nosniff
:MIMEタイプのスニッフィングを防止し、悪意のあるファイルアップロードのリスクを軽減します。X-Frame-Options: DENY
:クリックジャッキング攻撃から保護し、サイトがiframeに埋め込まれるのを防ぎます。Referrer-Policy: strict-origin-when-cross-origin
:リクエストに含まれるリファラー情報の量を制御し、セキュリティと機能性のバランスを取ります。
- サービスワーカー固有のヘッダー:
Content-Type: application/javascript; charset=utf-8
:サービスワーカーが正しくJavaScriptとして解釈されることを保証します。Cache-Control: no-cache, no-store, must-revalidate
:サービスワーカーのキャッシュを防止し、常に最新バージョンをユーザーに提供します。Content-Security-Policy: default-src 'self'; script-src 'self'
:サービスワーカーに対して厳格なコンテンツセキュリティポリシーを実装し、同じオリジンからのスクリプトのみを許可します。
Next.jsでのコンテンツセキュリティポリシーの定義について詳しく学びましょう。
次のステップ
- PWAの機能を探る:PWAはさまざまなWeb APIを活用して高度な機能を提供できます。バックグラウンド同期、定期的なバックグラウンド同期、またはファイルシステムアクセスAPIなどの機能を探求して、アプリケーションを強化することを検討してください。PWAの機能に関するインスピレーションや最新情報については、What PWA Can Do Todayなどのリソースを参照できます。
- 静的エクスポート:アプリケーションがサーバーを実行せず、代わりにファイルの静的エクスポートを使用する必要がある場合、Next.jsの設定を更新してこの変更を有効にできます。Next.js静的エクスポートのドキュメントで詳しく学びましょう。ただし、Server Actionsから外部APIの呼び出しに移行し、定義されたヘッダーをプロキシに移動する必要があります。
- オフラインサポート:オフライン機能を提供するための1つのオプションは、Next.jsとSerwistを使用することです。Next.jsとSerwistを統合する方法の例は、ドキュメントで見つけることができます。注意:このプラグインは現在、webpackの設定が必要です。
- セキュリティの考慮事項:サービスワーカーが適切に保護されていることを確認してください。これには、HTTPSの使用、プッシュメッセージのソースの検証、適切なエラーハンドリングの実装が含まれます。
- ユーザーエクスペリエンス:ユーザーのブラウザが特定のPWA機能をサポートしていない場合でも、アプリが正常に動作するように、プログレッシブエンハンスメント技術を実装することを検討してください。