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>
);
}
次に、これが呼び出すServer Actionsを作成します。
3. Server Actionsの実装
app/actions.ts
にアクションを含める新しいファイルを作成します。このファイルは、購読の作成、削除、および通知の送信を処理します。
- TypeScript
- JavaScript
'use server'
import webpush from 'web-push'
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';
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プッシュAPIを使用するには、VAPIDキーを生成する必要があります。
スクリプトファイルを作成します。例:generate-vapid-keys.js
:
const webpush = require('web-push')
const vapidKeys = webpush.generateVAPIDKeys()
console.log('以下のキーを.envファイルに貼り付けてください:')
console.log('-------------------')
console.log('NEXT_PUBLIC_VAPID_PUBLIC_KEY=', vapidKeys.publicKey)
console.log('VAPID_PRIVATE_KEY=', vapidKeys.privateKey)
このスクリプトをNode.jsで実行してVAPIDキーを生成します:
node generate-vapid-keys.js
出力をコピーして、.env
ファイルに貼り付けます。
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 app manifest(ステップ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 Static Export ドキュメントを参照してください。ただし、Server Actionsから外部APIの呼び出しおよび定義されたヘッダーをプロキシに移行する必要があります。
- オフラインサポート:オフライン機能を提供するオプションとして、Next.jsと共にSerwistを使用できます。Next.jsとの統合方法の例は、Serwist ドキュメントで確認できます。**注:**このプラグインは現在、webpackの設定が必要です。
- セキュリティの考慮事項:サービスワーカーが適切に保護されていることを確認してください。これには、HTTPSの使用、プッシュメッセージの送信元の検証、適切なエラーハンドリングの実装が含まれます。
- ユーザーエクスペリエンス:ユーザーのブラウザが特定のPWA機能をサポートしていない場合でもアプリが正常に動作するよう、プログレッシブエンハンスメント技術を検討してください。