بناء تطبيق ويب تقدمي (PWA) باستخدام Next.js App Router

تجمع تطبيقات الويب التقدمية (PWA) بين أفضل ما في الويب والتطبيقات الأصلية: التثبيت على الشاشة الرئيسية، والعمل بدون اتصال، وإشعارات الدفع، والأداء الأمثل. في هذا الدليل التعليمي، ستقوم بتحويل تطبيق Next.js App Router إلى تطبيق PWA كامل، خطوة بخطوة.
المتطلبات الأساسية
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبت على جهازك
- معرفة أساسية بـ Next.js App Router و TypeScript
- محرر أكواد (يُنصح بـ VS Code)
- متصفح متوافق مع PWA مثل Chrome أو Edge أو Firefox
ما ستبنيه
تطبيق Next.js يتميز بـ:
- التثبيت كتطبيق أصلي على الهاتف والحاسوب
- العمل بدون اتصال بفضل التخزين المؤقت الذكي
- إرسال إشعارات الدفع للمستخدمين
- المزامنة التلقائية عند عودة الاتصال
- الحصول على درجة 100 في اختبار Lighthouse PWA
الخطوة 1: إنشاء مشروع Next.js
لنبدأ بتهيئة مشروع Next.js جديد مع TypeScript:
npx create-next-app@latest my-pwa-app --typescript --tailwind --app --src-dir
cd my-pwa-appثبّت الحزم اللازمة لدعم PWA:
npm install next-pwa @ducanh2912/next-pwa
npm install -D webpackنستخدم @ducanh2912/next-pwa وهو النسخة المحدثة والمتوافقة مع Next.js 14+ و App Router.
الخطوة 2: تهيئة next-pwa
عدّل ملف next.config.ts لتفعيل دعم PWA:
// next.config.ts
import type { NextConfig } from "next";
import withPWAInit from "@ducanh2912/next-pwa";
const withPWA = withPWAInit({
dest: "public",
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
swcMinify: true,
disable: process.env.NODE_ENV === "development",
workboxOptions: {
disableDevLogs: true,
},
});
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default withPWA(nextConfig);هذا التكوين يقوم بـ:
- إنشاء Service Worker في مجلد
public/ - تفعيل التخزين المؤقت المكثف للتنقل في جانب العميل
- إعادة التحميل تلقائيًا عند عودة الاتصال
- تعطيل Service Worker في بيئة التطوير لتجنب التعارضات
الخطوة 3: إنشاء ملف Web App Manifest
يخبر ملف manifest المتصفح بكيفية عرض تطبيقك بعد التثبيت. أنشئ src/app/manifest.ts:
// src/app/manifest.ts
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "تطبيقي PWA",
short_name: "تطبيقي",
description: "تطبيق ويب تقدمي مبني بـ Next.js App Router",
start_url: "/",
display: "standalone",
background_color: "#0a0a0a",
theme_color: "#3b82f6",
orientation: "portrait-primary",
icons: [
{
src: "/icons/icon-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/icons/icon-384x384.png",
sizes: "384x384",
type: "image/png",
},
{
src: "/icons/icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
],
screenshots: [
{
src: "/screenshots/desktop.png",
sizes: "1280x720",
type: "image/png",
form_factor: "wide",
},
{
src: "/screenshots/mobile.png",
sizes: "390x844",
type: "image/png",
form_factor: "narrow",
},
],
categories: ["productivity", "utilities"],
};
}الأيقونات ذات purpose: "maskable" ضرورية لنظام Android. فهي تسمح للنظام بتطبيق قناع تكيفي على أيقونتك لتندمج بشكل متناسق مع التطبيقات الأخرى.
الخطوة 4: إضافة البيانات الوصفية لـ PWA
حدّث تخطيط الجذر لتضمين البيانات الوصفية اللازمة:
// src/app/layout.tsx
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "تطبيقي PWA",
description: "تطبيق PWA مبني بـ Next.js App Router",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "تطبيقي",
},
formatDetection: {
telephone: false,
},
};
export const viewport: Viewport = {
themeColor: "#3b82f6",
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ar" dir="rtl">
<head>
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
</head>
<body className="antialiased">{children}</body>
</html>
);
}الخطوة 5: إنشاء أيقونات PWA
أنشئ الأيقونات المطلوبة. يمكنك استخدام أداة مثل pwa-asset-generator:
mkdir -p public/icons public/screenshots
npx pwa-asset-generator ./public/logo.svg ./public/icons \
--icon-only --favicon --type png \
--padding "10%" --background "#0a0a0a"أو أنشئ الملفات التالية يدويًا:
public/
├── icons/
│ ├── icon-192x192.png
│ ├── icon-384x384.png
│ └── icon-512x512.png
├── screenshots/
│ ├── desktop.png
│ └── mobile.png
└── favicon.ico
الخطوة 6: تنفيذ التخزين المؤقت للعمل بدون اتصال
حدّث next.config.ts لتضمين قواعد التخزين المؤقت:
// next.config.ts
import type { NextConfig } from "next";
import withPWAInit from "@ducanh2912/next-pwa";
const withPWA = withPWAInit({
dest: "public",
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
swcMinify: true,
disable: process.env.NODE_ENV === "development",
workboxOptions: {
disableDevLogs: true,
runtimeCaching: [
{
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
handler: "CacheFirst",
options: {
cacheName: "image-cache",
expiration: {
maxEntries: 64,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 يوم
},
},
},
{
urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "google-fonts-cache",
expiration: {
maxEntries: 16,
maxAgeSeconds: 365 * 24 * 60 * 60, // سنة واحدة
},
},
},
{
urlPattern: /^https:\/\/api\..*$/i,
handler: "StaleWhileRevalidate",
options: {
cacheName: "api-cache",
expiration: {
maxEntries: 32,
maxAgeSeconds: 60 * 60, // ساعة واحدة
},
},
},
],
},
});
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default withPWA(nextConfig);الخطوة 7: إنشاء صفحة عدم الاتصال
عندما يكون المستخدم بدون اتصال ويحاول الوصول إلى صفحة غير مخزنة مؤقتًا، اعرض صفحة خطأ ودية:
// src/app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-950 text-white">
<div className="text-center">
<div className="mb-6 text-6xl">📡</div>
<h1 className="mb-4 text-3xl font-bold">
أنت غير متصل بالإنترنت
</h1>
<p className="mb-8 text-gray-400">
تحقق من اتصالك بالإنترنت وحاول مرة أخرى.
</p>
<button
onClick={() => window.location.reload()}
className="rounded-lg bg-blue-600 px-6 py-3 font-semibold
transition-colors hover:bg-blue-700"
>
إعادة المحاولة
</button>
</div>
</div>
);
}يتم تخزين صفحة /offline مؤقتًا تلقائيًا بواسطة next-pwa. عندما يفقد المستخدم الاتصال، يُعاد توجيهه إلى هذه الصفحة بدلاً من رؤية خطأ المتصفح الافتراضي.
الخطوة 8: اكتشاف حالة الاتصال
أنشئ hook مخصص للتفاعل مع تغييرات الاتصال:
// src/hooks/useOnlineStatus.ts
"use client";
import { useSyncExternalStore } from "react";
function subscribe(callback: () => void) {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true; // دائمًا متصل على الخادم
}
export function useOnlineStatus() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}استخدم هذا الـ hook في مكوّن شريط الإشعار:
// src/components/OnlineBanner.tsx
"use client";
import { useOnlineStatus } from "@/hooks/useOnlineStatus";
export function OnlineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-amber-600
px-4 py-2 text-center text-sm font-medium text-white">
⚠️ أنت حاليًا غير متصل بالإنترنت.
بعض الميزات قد تكون محدودة.
</div>
);
}الخطوة 9: زر تثبيت مخصص
أنشئ زر تثبيت أنيق يظهر عندما يعرض المتصفح خيار التثبيت:
// src/components/InstallPrompt.tsx
"use client";
import { useEffect, useState } from "react";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
if (window.matchMedia("(display-mode: standalone)").matches) {
setIsInstalled(true);
return;
}
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setIsVisible(true);
};
window.addEventListener("beforeinstallprompt", handler);
window.addEventListener("appinstalled", () => {
setIsInstalled(true);
setIsVisible(false);
setDeferredPrompt(null);
});
return () => {
window.removeEventListener("beforeinstallprompt", handler);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
setIsVisible(false);
}
setDeferredPrompt(null);
};
if (!isVisible || isInstalled) return null;
return (
<div className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-md
rounded-xl bg-gray-900 p-4 shadow-2xl border
border-gray-700 md:left-auto md:right-4">
<div className="flex items-center gap-4">
<div className="flex-shrink-0 text-3xl">📱</div>
<div className="flex-1">
<h3 className="font-semibold text-white">
تثبيت التطبيق
</h3>
<p className="text-sm text-gray-400">
وصول سريع من شاشتك الرئيسية
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setIsVisible(false)}
className="rounded-lg px-3 py-2 text-sm text-gray-400
hover:text-white transition-colors"
>
لاحقًا
</button>
<button
onClick={handleInstall}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm
font-semibold text-white hover:bg-blue-700
transition-colors"
>
تثبيت
</button>
</div>
</div>
</div>
);
}الخطوة 10: إشعارات الدفع
نفّذ إشعارات الدفع لإشراك مستخدميك:
// src/components/PushNotification.tsx
"use client";
import { useEffect, useState } from "react";
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
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;
}
export function PushNotification() {
const [permission, setPermission] =
useState<NotificationPermission>("default");
const [isSubscribed, setIsSubscribed] = useState(false);
useEffect(() => {
if ("Notification" in window) {
setPermission(Notification.permission);
}
}, []);
const subscribeToNotifications = async () => {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
setIsSubscribed(true);
setPermission("granted");
} catch (error) {
console.error("خطأ في الاشتراك بالإشعارات:", error);
}
};
if (permission === "denied") return null;
if (isSubscribed) return null;
return (
<button
onClick={subscribeToNotifications}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4
py-2 text-sm font-semibold text-white
hover:bg-green-700 transition-colors"
>
🔔 تفعيل الإشعارات
</button>
);
}الخطوة 11: المزامنة في الخلفية
نفّذ مزامنة البيانات عند عودة الاتصال:
// src/lib/backgroundSync.ts
"use client";
interface SyncData {
url: string;
method: string;
body: string;
timestamp: number;
}
const DB_NAME = "pwa-sync-queue";
const STORE_NAME = "pending-requests";
async function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME, {
autoIncrement: true,
});
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function queueRequest(
url: string,
method: string,
body: object
) {
const db = await openDB();
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const syncData: SyncData = {
url,
method,
body: JSON.stringify(body),
timestamp: Date.now(),
};
store.add(syncData);
if ("serviceWorker" in navigator && "SyncManager" in window) {
const registration = await navigator.serviceWorker.ready;
await (registration as any).sync.register("sync-pending");
}
}
export async function processPendingRequests() {
const db = await openDB();
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const allRequests = await new Promise<SyncData[]>((resolve) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
});
for (const syncData of allRequests) {
try {
await fetch(syncData.url, {
method: syncData.method,
headers: { "Content-Type": "application/json" },
body: syncData.body,
});
} catch {
return;
}
}
const clearTx = db.transaction(STORE_NAME, "readwrite");
clearTx.objectStore(STORE_NAME).clear();
}الخطوة 12: تجميع كل شيء معًا
حدّث صفحتك الرئيسية لدمج جميع المكونات:
// src/app/page.tsx
import { InstallPrompt } from "@/components/InstallPrompt";
import { OnlineBanner } from "@/components/OnlineBanner";
import { PushNotification } from "@/components/PushNotification";
import { ContactForm } from "@/components/ContactForm";
export default function Home() {
return (
<>
<OnlineBanner />
<main className="min-h-screen bg-gray-950 text-white">
<div className="mx-auto max-w-4xl px-4 py-16">
<h1 className="mb-4 text-5xl font-bold bg-gradient-to-r
from-blue-400 to-purple-500 bg-clip-text
text-transparent">
تطبيقي PWA
</h1>
<p className="mb-8 text-xl text-gray-400">
قابل للتثبيت، يعمل بدون اتصال، سريع.
</p>
<div className="mb-12 grid gap-6 md:grid-cols-3">
<FeatureCard
icon="📱"
title="قابل للتثبيت"
description="أضف التطبيق إلى شاشتك الرئيسية"
/>
<FeatureCard
icon="🔌"
title="بدون اتصال"
description="يعمل بدون اتصال بالإنترنت"
/>
<FeatureCard
icon="🔔"
title="إشعارات"
description="ابقَ على اطلاع في الوقت الحقيقي"
/>
</div>
<div className="mb-8">
<PushNotification />
</div>
<section className="rounded-xl border border-gray-800 p-6">
<h2 className="mb-4 text-2xl font-semibold">
تواصل معنا
</h2>
<ContactForm />
</section>
</div>
</main>
<InstallPrompt />
</>
);
}
function FeatureCard({
icon,
title,
description,
}: {
icon: string;
title: string;
description: string;
}) {
return (
<div className="rounded-xl border border-gray-800 p-6 text-center
hover:border-gray-600 transition-colors">
<div className="mb-3 text-4xl">{icon}</div>
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
<p className="text-sm text-gray-400">{description}</p>
</div>
);
}اختبار تطبيق PWA
الاختبار المحلي
قم بتشغيل بناء إنتاجي لاختبار Service Worker:
npm run build
npm startلا يعمل Service Worker في وضع التطوير (npm run dev). يجب دائمًا الاختبار ببناء إنتاجي.
تدقيق Lighthouse
- افتح أدوات مطور Chrome (F12)
- انتقل إلى تبويب Lighthouse
- حدد Progressive Web App
- شغّل التدقيق
يجب أن يحصل تطبيقك على درجة قريبة من 100 إذا تم استيفاء جميع المعايير.
الفحوصات اليدوية
اختبر السيناريوهات التالية:
- التثبيت: انقر على أيقونة التثبيت في شريط العناوين
- بدون اتصال: فعّل وضع الطيران وتصفح التطبيق
- التخزين المؤقت: زر الصفحات، انتقل لوضع عدم الاتصال، ثم أعد زيارتها
- الإشعارات: فعّل الإشعارات وأرسل اختبارًا
إضافة إلى .gitignore
ملفات Service Worker والتخزين المؤقت المُنشأة لا يجب تتبعها في Git:
# PWA
public/sw.js
public/sw.js.map
public/workbox-*.js
public/workbox-*.js.map
public/worker-*.js
public/worker-*.js.map
public/fallback-*.js
public/swe-worker-*.jsاستكشاف الأخطاء وإصلاحها
Service Worker لا يتحدث
امسح ذاكرة التخزين المؤقت في أدوات مطور Chrome، تبويب التطبيق، قسم Service Workers، وانقر على "Unregister". ثم أعد تحميل الصفحة.
لا يظهر طلب التثبيت
حدث beforeinstallprompt يُطلق فقط إذا:
- الصفحة تُقدم عبر HTTPS (أو localhost)
- ملف manifest صالح مع الحقول المطلوبة
- تم تسجيل Service Worker
- لم يقم المستخدم بتثبيت التطبيق بالفعل
الإشعارات لا تعمل
تحقق من أن:
- مفاتيح VAPID مُهيأة بشكل صحيح
- Service Worker نشط
- المستخدم منح الإذن
- المتصفح يدعم واجهة Push API
الخطوات التالية
- أضف المشاركة الأصلية مع Web Share API
- نفّذ الوضع الداكن التكيفي مع
prefers-color-scheme - استكشف Periodic Background Sync للتحديثات المنتظمة
- ادمج Workbox لاستراتيجيات تخزين مؤقت متقدمة
- أضف دعم اختصارات التطبيق في ملف manifest
الخلاصة
لقد حوّلت تطبيق Next.js إلى تطبيق ويب تقدمي كامل. تطبيقك الآن قابل للتثبيت على جميع الأجهزة، ويعمل بدون اتصال بفضل التخزين المؤقت الذكي، ويمكنه إرسال إشعارات الدفع لإشراك مستخدميك.
تمثل تطبيقات PWA أفضل حل وسط بين تطبيقات الويب والتطبيقات الأصلية: قاعدة كود واحدة، نشر فوري عبر الرابط، وإمكانيات أصلية. مع Next.js App Router والأدوات الحديثة، أصبح إنشاء PWA أسهل من أي وقت مضى.
ناقش مشروعك معنا
نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.
دعنا نجد أفضل الحلول لاحتياجاتك.
مقالات ذات صلة

بناء واجهة برمجية احترافية باستخدام tRPC و Prisma و Next.js
تعلم كيفية بناء واجهة برمجية آمنة من حيث الأنواع (Type-Safe) جاهزة للإنتاج باستخدام tRPC و Prisma ORM و Next.js 15. دليل شامل من الإعداد إلى النشر مع أفضل الممارسات.

الدليل التفصيلي لتثبيت وهيكلة تطبيقك في Next.js لأداء أمثل
الدليل التفصيلي لتثبيت وهيكلة تطبيقك في Next.js لأداء أمثل: عزز تطبيق Next.js الخاص بك باستخدام هذا الدليل الشامل حول التثبيت وأفضل الممارسات لهيكلة مشروعك لتحقيق الأداء الأمثل.

AI SDK 4.0: الميزات الجديدة وحالات الاستخدام
اكتشف الميزات الجديدة وحالات الاستخدام لـ AI SDK 4.0، بما في ذلك دعم PDF واستخدام الكمبيوتر والمزيد.