كيفية بناء تطبيق ويب تقدمي (PWA) باستخدام Next.js

تقدم تطبيقات الويب التقدمية (PWAs) وصولاً وسهولة استخدام تطبيقات الويب مع ميزات وتجربة مستخدم مشابهة للتطبيقات الأصلية على الهواتف. باستخدام Next.js، يمكنك إنشاء تطبيقات PWA توفر تجربة سلسة تشبه التطبيقات عبر جميع المنصات دون الحاجة إلى أكواد متعددة أو موافقات من متجر التطبيقات.

تتيح لك تطبيقات PWA:

  • نشر التحديثات فورًا دون انتظار موافقة متجر التطبيقات
  • إنشاء تطبيقات متعددة المنصات باستخدام كود أساسي واحد
  • توفير ميزات تشبه التطبيقات الأصلية مثل التثبيت على الشاشة الرئيسية وإشعارات الدفع

بناء تطبيق PWA باستخدام Next.js

1. إنشاء ملف Manifest لتطبيق الويب

يوفر Next.js دعمًا مدمجًا لإنشاء ملف manifest لتطبيق الويب باستخدام App Router. يمكنك إنشاء ملف manifest ثابت أو ديناميكي:

على سبيل المثال، أنشئ ملف app/manifest.ts أو app/manifest.json:

import type { MetadataRoute } from 'next'

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'Next.js PWA',
    short_name: 'NextPWA',
    description: 'A Progressive Web App built with Next.js',
    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: 'A Progressive Web App built with Next.js',
    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 على شاشتهم الرئيسية، مما يوفر تجربة تشبه التطبيق الأصلي.

يمكنك استخدام أدوات مثل مولدات الأيقونات لإنشاء مجموعات مختلفة من الأيقونات ووضع الملفات المُنشأة في مجلد public/.

2. تنفيذ إشعارات الدفع عبر الويب

تدعم جميع المتصفحات الحديثة إشعارات الدفع عبر الويب، بما في ذلك:

  • iOS 16.4+ للتطبيقات المثبتة على الشاشة الرئيسية
  • Safari 16 لنظام macOS 13 أو أحدث
  • متصفحات Chromium
  • Firefox

هذا يجعل تطبيقات PWA بديلاً قابلاً للتطبيق للتطبيقات الأصلية. من الجدير بالذكر أنه يمكنك تشغيل مطالبات التثبيت دون الحاجة إلى دعم وضع عدم الاتصال.

تسمح لك إشعارات الدفع عبر الويب بإعادة جذب المستخدمين حتى عندما لا يستخدمون تطبيقك بنشاط. إليك كيفية تنفيذها في تطبيق Next.js:

أولاً، لننشئ مكون الصفحة الرئيسية في app/page.tsx. سنقسمه إلى أجزاء أصغر لفهم أفضل. أولاً، سنضيف بعض الاستيرادات والأدوات المساعدة التي سنحتاجها. لا بأس إذا كانت إجراءات الخادم المشار إليها غير موجودة بعد:

'use client'

import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser, sendNotification } from './actions'

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'

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>Push notifications are not supported in this browser.</p>
  }

  return (
    <div>
      <h3>Push Notifications</h3>
      {subscription ? (
        <>
          <p>You are subscribed to push notifications.</p>
          <button onClick={unsubscribeFromPush}>Unsubscribe</button>
          <input
            type="text"
            placeholder="Enter notification message"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
          />
          <button onClick={sendTestNotification}>Send Test</button>
        </>
      ) : (
        <>
          <p>You are not subscribed to push notifications.</p>
          <button onClick={subscribeToPush}>Subscribe</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>Push notifications are not supported in this browser.</p>;
  }

  return (
    <div>
      <h3>Push Notifications</h3>
      {subscription ? (
        <>
          <p>You are subscribed to push notifications.</p>
          <button onClick={unsubscribeFromPush}>Unsubscribe</button>
          <input
            type="text"
            placeholder="Enter notification message"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
          />
          <button onClick={sendTestNotification}>Send Test</button>
        </>
      ) : (
        <>
          <p>You are not subscribed to push notifications.</p>
          <button onClick={subscribeToPush}>Subscribe</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 // Don't show install button if already installed
  }

  return (
    <div>
      <h3>Install App</h3>
      <button>Add to Home Screen</button>
      {isIOS && (
        <p>
          To install this app on your iOS device, tap the share button
          <span role="img" aria-label="share icon">
            {' '}
            ⎋{' '}
          </span>
          and then "Add to Home Screen"
          <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; // Don't show install button if already installed
  }

  return (
    <div>
      <h3>Install App</h3>
      <button>Add to Home Screen</button>
      {isIOS && (
        <p>
          To install this app on your iOS device, tap the share button
          <span role="img" aria-label="share icon">
            {' '}
            ⎋{' '}
          </span>
          and then "Add to Home Screen"
          <span role="img" aria-label="plus icon">
            {' '}
            ➕{' '}
          </span>
          .
        </p>
      )}
    </div>
  );
}

export default function Page() {
  return (
    <div>
      <PushNotificationManager />
      <InstallPrompt />
    </div>
  );
}

الآن، لننشئ إجراءات الخادم التي يستدعيها هذا الملف.

3. تنفيذ إجراءات الخادم

أنشئ ملفًا جديدًا يحتوي على إجراءاتك في app/actions.ts. سيتولى هذا الملف معالجة إنشاء الاشتراكات وحذفها وإرسال الإشعارات.

'use server'

import webpush from 'web-push'

webpush.setVapidDetails(
  '<mailto:[email protected]>',
  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
  // In a production environment, you would want to store the subscription in a database
  // For example: await db.subscriptions.create({ data: sub })
  return { success: true }
}

export async function unsubscribeUser() {
  subscription = null
  // In a production environment, you would want to remove the subscription from the database
  // For example: await db.subscriptions.delete({ where: { ... } })
  return { success: true }
}

export async function sendNotification(message: string) {
  if (!subscription) {
    throw new Error('No subscription available')
  }

  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify({
        title: 'Test Notification',
        body: message,
        icon: '/icon.png',
      })
    )
    return { success: true }
  } catch (error) {
    console.error('Error sending push notification:', error)
    return { success: false, error: 'Failed to send notification' }
  }
}
'use server';

import webpush from 'web-push';

webpush.setVapidDetails(
  '<mailto:[email protected]>',
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
);

let subscription= null;

export async function subscribeUser(sub) {
  subscription = sub;
  // In a production environment, you would want to store the subscription in a database
  // For example: await db.subscriptions.create({ data: sub })
  return { success: true };
}

export async function unsubscribeUser() {
  subscription = null;
  // In a production environment, you would want to remove the subscription from the database
  // For example: await db.subscriptions.delete({ where: { ... } })
  return { success: true };
}

export async function sendNotification(message) {
  if (!subscription) {
    throw new Error('No subscription available');
  }

  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify({
        title: 'Test Notification',
        body: message,
        icon: '/icon.png',
      })
    );
    return { success: true };
  } catch (error) {
    console.error('Error sending push notification:', error);
    return { success: false, error: 'Failed to send notification' };
  }
}

سيتم التعامل مع إرسال الإشعارات بواسطة عامل الخدمة الذي سننشئه في الخطوة 5.

في بيئة إنتاج، سترغب في تخزين الاشتراك في قاعدة بيانات للاستمرارية عبر إعادة تشغيل الخادم ولإدارة اشتراكات مستخدمين متعددين.

4. إنشاء مفاتيح VAPID

لاستخدام Web Push API، تحتاج إلى إنشاء مفاتيح VAPID. أسهل طريقة هي استخدام واجهة سطر أوامر web-push مباشرة:

أولاً، قم بتثبيت web-push عالميًا:

Terminal
npm install -g web-push

قم بإنشاء مفاتيح VAPID عن طريق تشغيل:

Terminal
web-push generate-vapid-keys

انسخ الناتج والصق المفاتيح في ملف .env الخاص بك:

NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here

5. إنشاء عامل خدمة (Service Worker)

أنشئ ملف public/sw.js لعامل الخدمة الخاص بك:

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('Notification click received.')
  event.notification.close()
  event.waitUntil(clients.openWindow('<https://your-website.com>'))
}

يدعم عامل الخدمة هذا الصور المخصصة والإشعارات. يتعامل مع أحداث الدفع الواردة ونقرات الإشعارات.

  • يمكنك تعيين أيقونات مخصصة للإشعارات باستخدام خصائص icon و badge.
  • يمكن تعديل نمط vibrate لإنشاء تنبيهات اهتزاز مخصصة على الأجهزة المدعومة.
  • يمكن إرفاق بيانات إضافية بالإشعار باستخدام خاصية data.

تذكر اختبار عامل الخدمة الخاص بك بدقة للتأكد من أنه يعمل كما هو متوقع عبر الأجهزة والمتصفحات المختلفة. أيضًا، تأكد من تحديث الرابط 'https://your-website.com' في مستمع حدث notificationclick إلى الرابط المناسب لتطبيقك.

6. إضافة إلى الشاشة الرئيسية

مكون InstallPrompt الذي تم تعريفه في الخطوة 2 يعرض رسالة لأجهزة iOS لإرشادهم حول كيفية التثبيت على الشاشة الرئيسية.

لضمان إمكانية تثبيت تطبيقك على الشاشة الرئيسية للأجهزة المحمولة، يجب أن يتوفر لديك:

  1. ملف بيان تطبيق ويب صالح (تم إنشاؤه في الخطوة 1)
  2. أن يتم تقديم الموقع عبر بروتوكول HTTPS

ستعرض المتصفحات الحديثة تلقائيًا رسالة تثبيت للمستخدمين عند استيفاء هذه الشروط. يمكنك توفير زر تثبيت مخصص باستخدام beforeinstallprompt، لكننا لا نوصي بذلك لأنه غير متوافق مع جميع المتصفحات والمنصات (لا يعمل على Safari iOS).

7. الاختبار محليًا

للتأكد من إمكانية عرض الإشعارات محليًا، تأكد من:

  • أنك تعمل محليًا باستخدام HTTPS
    • استخدم next dev --experimental-https للاختبار
  • أن المتصفح (Chrome، Safari، Firefox) لديه تمكين الإشعارات
    • عند المطالبة محليًا، قم بقبول الأذونات لاستخدام الإشعارات
    • تأكد من عدم تعطيل الإشعارات عالميًا للمتصفح بالكامل
    • إذا كنت لا تزال لا ترى الإشعارات، جرب استخدام متصفح آخر لتصحيح الأخطاء

8. تأمين تطبيقك

الأمان هو جانب حاسم في أي تطبيق ويب، خاصة لتطبيقات PWA. يسمح لك Next.js بتكوين رؤوس الأمان باستخدام ملف next.config.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'",
          },
        ],
      },
    ]
  },
}

دعونا نستعرض كل من هذه الخيارات:

  1. رؤوس عامة (تطبق على جميع المسارات):
    1. X-Content-Type-Options: nosniff: يمنع استنشاق نوع MIME، مما يقلل من خطر تحميل الملفات الضارة.
    2. X-Frame-Options: DENY: يحمي من هجمات clickjacking عن طريق منع تضمين موقعك في iframes.
    3. Referrer-Policy: strict-origin-when-cross-origin: يتحكم في مقدار معلومات المرجع المضمنة مع الطلبات، موازنة بين الأمان والوظائف.
  2. رؤوس خاصة بعامل الخدمة (Service Worker):
    1. Content-Type: application/javascript; charset=utf-8: يضمن تفسير عامل الخدمة بشكل صحيح كـ JavaScript.
    2. Cache-Control: no-cache, no-store, must-revalidate: يمنع تخزين عامل الخدمة مؤقتًا، مما يضمن حصول المستخدمين دائمًا على أحدث إصدار.
    3. Content-Security-Policy: default-src 'self'; script-src 'self': ينفذ سياسة أمان محتوى صارمة لعامل الخدمة، مما يسمح فقط بالنصوص البرمجية من نفس المصدر.

تعرف على المزيد حول تعريف سياسات أمان المحتوى (Content Security Policies) مع Next.js.

الخطوات التالية

  1. استكشاف إمكانيات PWA: يمكن لتطبيقات PWA الاستفادة من واجهات برمجة تطبيقات الويب المختلفة لتوفير وظائف متقدمة. فكر في استكشاف ميزات مثل المزامنة في الخلفية، المزامنة الدورية في الخلفية، أو واجهة برمجة تطبيقات نظام الملفات لتعزيز تطبيقك. للإلهام ومعلومات محدثة حول إمكانيات PWA، يمكنك الرجوع إلى موارد مثل What PWA Can Do Today.
  2. التصدير الثابت (Static Exports): إذا كان تطبيقك لا يتطلب تشغيل خادم، وبدلاً من ذلك يستخدم تصديرًا ثابتًا للملفات، يمكنك تحديث تكوين Next.js لتمكين هذا التغيير. تعلم المزيد في توثيق التصدير الثابت لـ Next.js. ومع ذلك، ستحتاج إلى الانتقال من إجراءات الخادم إلى استدعاء واجهة برمجة تطبيقات خارجية، وكذلك نقل الرؤوس المحددة إلى الوكيل الخاص بك.
  3. دعم عدم الاتصال (Offline Support): لتوفير وظائف عدم الاتصال، أحد الخيارات هو Serwist مع Next.js. يمكنك العثور على مثال لكيفية دمج Serwist مع Next.js في توثيقهم. ملاحظة: هذا الملحق يتطلب حاليًا تكوين webpack.
  4. اعتبارات الأمان: تأكد من تأمين عامل الخدمة (Service Worker) بشكل صحيح. وهذا يشمل استخدام HTTPS، التحقق من مصدر رسائل الدفع، وتنفيذ معالجة الأخطاء المناسبة.
  5. تجربة المستخدم: فكر في تنفيذ تقنيات التحسين التدريجي (progressive enhancement) لضمان عمل تطبيقك بشكل جيد حتى عندما لا تدعم متصفحات المستخدمين بعض ميزات PWA.