Progressive Web Apps (PWA) to nowoczesne aplikacje webowe, które łączą najlepsze cechy aplikacji internetowych i natywnych. Next.js oferuje doskonale wsparcie dla PWA, pozwalając na tworzenie szybkich, niezawodnych i angażujących aplikacji, które działają także offline. W tym module poznamy, jak zaimplementować PWA w Next.js przy użyciu Service Workers.
Progressive Web Apps to aplikacje webowe, które wykorzystują nowoczesne technologie webowe, aby zapewnić użytkownikom doświadczenie podobne do aplikacji natywnych. Kluczowe cechy PWA to:
Service Workers to skrypty JavaScript, które działają w tle, niezależnie od głównego wątku aplikacji. Pozwalają na:
Najprostszym sposobem dodania funkcjonalności PWA do aplikacji Next.js jest użycie pakietu
next-pwa:1npm install next-pwa
2# lub
3yarn add next-pwaZaktualizuj
next.config.js:1// next.config.js
2const withPWA = require('next-pwa')({
3 dest: 'public',
4 register: true,
5 skipWaiting: true,
6 disable: process.env.NODE_ENV === 'development', // Wyłącz w development
7 runtimeCaching: [
8 {
9 urlPattern: /^https?.*/,
10 handler: 'NetworkFirst',
11 options: {
12 cacheName: 'offlineCache',
13 expiration: {
14 maxEntries: 200,
15 maxAgeSeconds: 24 * 60 * 60, // 24 godziny
16 },
17 },
18 },
19 ],
20});
21
22module.exports = withPWA({
23 // Twoja standardowa konfiguracja Next.js
24 reactStrictMode: true,
25 swcMinify: true,
26});Manifest to plik JSON, który zawiera metadane o aplikacji PWA. Utwórz plik
public/manifest.json:1{
2 "name": "Moja Aplikacja PWA",
3 "short_name": "MojaApp",
4 "description": "Przykładowa aplikacja Progressive Web App zbudowana z Next.js",
5 "start_url": "/",
6 "display": "standalone",
7 "background_color": "#ffffff",
8 "theme_color": "#000000",
9 "orientation": "portrait-primary",
10 "icons": [
11 {
12 "src": "/icons/icon-72x72.png",
13 "sizes": "72x72",
14 "type": "image/png",
15 "purpose": "maskable any"
16 },
17 {
18 "src": "/icons/icon-96x96.png",
19 "sizes": "96x96",
20 "type": "image/png",
21 "purpose": "maskable any"
22 },
23 {
24 "src": "/icons/icon-128x128.png",
25 "sizes": "128x128",
26 "type": "image/png",
27 "purpose": "maskable any"
28 },
29 {
30 "src": "/icons/icon-144x144.png",
31 "sizes": "144x144",
32 "type": "image/png",
33 "purpose": "maskable any"
34 },
35 {
36 "src": "/icons/icon-152x152.png",
37 "sizes": "152x152",
38 "type": "image/png",
39 "purpose": "maskable any"
40 },
41 {
42 "src": "/icons/icon-192x192.png",
43 "sizes": "192x192",
44 "type": "image/png",
45 "purpose": "maskable any"
46 },
47 {
48 "src": "/icons/icon-384x384.png",
49 "sizes": "384x384",
50 "type": "image/png",
51 "purpose": "maskable any"
52 },
53 {
54 "src": "/icons/icon-512x512.png",
55 "sizes": "512x512",
56 "type": "image/png",
57 "purpose": "maskable any"
58 }
59 ],
60 "categories": ["productivity", "utilities"],
61 "screenshots": [
62 {
63 "src": "/screenshots/desktop.png",
64 "sizes": "1280x720",
65 "type": "image/png",
66 "form_factor": "wide"
67 },
68 {
69 "src": "/screenshots/mobile.png",
70 "sizes": "750x1334",
71 "type": "image/png",
72 "form_factor": "narrow"
73 }
74 ]
75}Zaktualizuj główny layout aplikacji, aby dodać niezbędne meta tagi:
1// app/layout.tsx
2import type { Metadata, Viewport } from 'next';
3
4export const metadata: Metadata = {
5 title: 'Moja Aplikacja PWA',
6 description: 'Przykładowa aplikacja Progressive Web App',
7 generator: 'Next.js',
8 manifest: '/manifest.json',
9 keywords: ['nextjs', 'pwa', 'react'],
10 authors: [
11 { name: 'Twój Zespół' },
12 ],
13 creator: 'Twój Zespół',
14 formatDetection: {
15 email: false,
16 address: false,
17 telephone: false,
18 },
19 metadataBase: new URL('https://twoja-domena.com'),
20 alternates: {
21 canonical: '/',
22 },
23 openGraph: {
24 type: 'website',
25 siteName: 'Moja Aplikacja PWA',
26 title: {
27 default: 'Moja Aplikacja PWA',
28 template: '%s | Moja Aplikacja PWA',
29 },
30 description: 'Przykładowa aplikacja Progressive Web App',
31 },
32 twitter: {
33 card: 'summary',
34 title: {
35 default: 'Moja Aplikacja PWA',
36 template: '%s | Moja Aplikacja PWA',
37 },
38 description: 'Przykładowa aplikacja Progressive Web App',
39 },
40 appleWebApp: {
41 title: 'Moja Aplikacja PWA',
42 statusBarStyle: 'default',
43 capable: true,
44 },
45};
46
47export const viewport: Viewport = {
48 themeColor: [
49 { media: '(prefers-color-scheme: light)', color: '#ffffff' },
50 { media: '(prefers-color-scheme: dark)', color: '#000000' },
51 ],
52 minimumScale: 1,
53 initialScale: 1,
54 width: 'device-width',
55 shrinkToFit: false,
56 viewportFit: 'cover',
57};
58
59export default function RootLayout({
60 children,
61}: {
62 children: React.ReactNode;
63}) {
64 return (
65 <html lang="pl">
66 <head>
67 <link rel="icon" href="/favicon.ico" />
68 <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
69 </head>
70 <body>
71 {children}
72 </body>
73 </html>
74 );
75}Jeśli potrzebujesz większej kontroli nad Service Worker, możesz stworzyć własną implementację:
1// public/sw.js
2const CACHE_NAME = 'moja-app-v1';
3const urlsToCache = [
4 '/',
5 '/static/js/bundle.js',
6 '/static/css/main.css',
7 '/manifest.json',
8];
9
10// Install event - cachowanie zasobów
11self.addEventListener('install', (event) => {
12 event.waitUntil(
13 caches.open(CACHE_NAME)
14 .then((cache) => {
15 console.log('Cache otwarty');
16 return cache.addAll(urlsToCache);
17 })
18 .then(() => {
19 // Wymusza aktywację nowego Service Worker
20 return self.skipWaiting();
21 })
22 );
23});
24
25// Activate event - czyszczenie starych cache
26self.addEventListener('activate', (event) => {
27 event.waitUntil(
28 caches.keys().then((cacheNames) => {
29 return Promise.all(
30 cacheNames.map((cacheName) => {
31 if (cacheName !== CACHE_NAME) {
32 console.log('Usuwanie starego cache:', cacheName);
33 return caches.delete(cacheName);
34 }
35 })
36 );
37 }).then(() => {
38 // Przejmij kontrolę nad wszystkimi klientami
39 return self.clients.claim();
40 })
41 );
42});
43
44// Fetch event - strategia cache-first
45self.addEventListener('fetch', (event) => {
46 event.respondWith(
47 caches.match(event.request)
48 .then((response) => {
49 // Zwróć z cache jeśli dostępne
50 if (response) {
51 return response;
52 }
53
54 // W przeciwnym razie pobierz z sieci
55 return fetch(event.request).then((response) => {
56 // Sprawdź, czy otrzymaliśmy poprawną odpowiedź
57 if (!response || response.status !== 200 || response.type !== 'basic') {
58 return response;
59 }
60
61 // Sklonuj odpowiedź
62 const responseToCache = response.clone();
63
64 caches.open(CACHE_NAME)
65 .then((cache) => {
66 cache.put(event.request, responseToCache);
67 });
68
69 return response;
70 }).catch(() => {
71 // Jeśli żądanie nie powiodło się, zwróć stronę offline
72 if (event.request.destination === 'document') {
73 return caches.match('/offline.html');
74 }
75 });
76 })
77 );
78});
79
80// Background sync
81self.addEventListener('sync', (event) => {
82 if (event.tag === 'background-sync') {
83 event.waitUntil(doBackgroundSync());
84 }
85});
86
87function doBackgroundSync() {
88 // Implementacja synchronizacji w tle
89 return fetch('/api/sync')
90 .then(response => response.json())
91 .then(data => {
92 console.log('Background sync completed:', data);
93 })
94 .catch(err => {
95 console.error('Background sync failed:', err);
96 });
97}1// lib/serviceWorker.ts
2export function registerSW() {
3 if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
4 window.addEventListener('load', () => {
5 navigator.serviceWorker.register('/sw.js')
6 .then((registration) => {
7 console.log('SW zarejestrowany: ', registration);
8
9 // Sprawdzenie aktualizacji
10 registration.addEventListener('updatefound', () => {
11 const newWorker = registration.installing;
12 if (newWorker) {
13 newWorker.addEventListener('statechange', () => {
14 if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
15 // Nowy Service Worker jest dostępny
16 showUpdateAvailableNotification();
17 }
18 });
19 }
20 });
21 })
22 .catch((error) => {
23 console.log('Rejestracja SW nie powiodła się: ', error);
24 });
25 });
26 }
27}
28
29export function unregisterSW() {
30 if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
31 navigator.serviceWorker.ready.then((registration) => {
32 registration.unregister();
33 });
34 }
35}
36
37function showUpdateAvailableNotification() {
38 // Pokaż powiadomienie o dostępnej aktualizacji
39 if (confirm('Nowa wersja aplikacji jest dostępna. Czy chcesz ją zaaktualizować?')) {
40 window.location.reload();
41 }
42}1// app/page.tsx
2'use client';
3
4import { useEffect } from 'react';
5import { registerSW } from '@/lib/serviceWorker';
6
7export default function HomePage() {
8 useEffect(() => {
9 registerSW();
10 }, []);
11
12 return (
13 <div>
14 <h1>Moja Aplikacja PWA</h1>
15 <p>Witaj w aplikacji Progressive Web App!</p>
16 </div>
17 );
18}Różne typy zawartości wymagają różnych strategii cachowania:
1// Strategia Cache First
2self.addEventListener('fetch', (event) => {
3 if (event.request.destination === 'image' ||
4 event.request.destination === 'style' ||
5 event.request.destination === 'script') {
6 event.respondWith(
7 caches.match(event.request).then((cachedResponse) => {
8 return cachedResponse || fetch(event.request).then((response) => {
9 const responseClone = response.clone();
10 caches.open(CACHE_NAME).then((cache) => {
11 cache.put(event.request, responseClone);
12 });
13 return response;
14 });
15 })
16 );
17 }
18});1// Strategia Network First
2self.addEventListener('fetch', (event) => {
3 if (event.request.url.includes('/api/')) {
4 event.respondWith(
5 fetch(event.request)
6 .then((response) => {
7 const responseClone = response.clone();
8 caches.open(CACHE_NAME).then((cache) => {
9 cache.put(event.request, responseClone);
10 });
11 return response;
12 })
13 .catch(() => {
14 return caches.match(event.request);
15 })
16 );
17 }
18});1// Strategia Stale While Revalidate
2self.addEventListener('fetch', (event) => {
3 if (event.request.url.includes('/news/')) {
4 event.respondWith(
5 caches.match(event.request).then((cachedResponse) => {
6 const fetchPromise = fetch(event.request).then((networkResponse) => {
7 caches.open(CACHE_NAME).then((cache) => {
8 cache.put(event.request, networkResponse.clone());
9 });
10 return networkResponse;
11 });
12
13 return cachedResponse || fetchPromise;
14 })
15 );
16 }
17});Utwórz dedykowaną stronę offline:
1// app/offline/page.tsx
2export default function OfflinePage() {
3 return (
4 <div className="min-h-screen flex items-center justify-center bg-gray-100">
5 <div className="text-center">
6 <div className="mb-4">
7 <svg className="mx-auto h-16 w-16 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
8 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M12 2.25a9.75 9.75 0 1 0 0 19.5 9.75 9.75 0 0 0 0-19.5Z" />
9 </svg>
10 </div>
11 <h1 className="text-2xl font-bold text-gray-900 mb-2">Jesteś offline</h1>
12 <p className="text-gray-600 mb-4">
13 Nie możemy połączyć się z internetem. Sprawdź połączenie i spróbuj ponownie.
14 </p>
15 <button
16 onClick={() => window.location.reload()}
17 className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded"
18 >
19 Spróbuj ponownie
20 </button>
21 </div>
22 </div>
23 );
24}1// hooks/useOnlineStatus.ts
2import { useState, useEffect } from 'react';
3
4export function useOnlineStatus() {
5 const [isOnline, setIsOnline] = useState(true);
6
7 useEffect(() => {
8 function handleOnline() {
9 setIsOnline(true);
10 }
11
12 function handleOffline() {
13 setIsOnline(false);
14 }
15
16 window.addEventListener('online', handleOnline);
17 window.addEventListener('offline', handleOffline);
18
19 // Sprawdź początkowy stan
20 setIsOnline(navigator.onLine);
21
22 return () => {
23 window.removeEventListener('online', handleOnline);
24 window.removeEventListener('offline', handleOffline);
25 };
26 }, []);
27
28 return isOnline;
29}1// components/OnlineStatusIndicator.tsx
2'use client';
3
4import { useOnlineStatus } from '@/hooks/useOnlineStatus';
5
6export default function OnlineStatusIndicator() {
7 const isOnline = useOnlineStatus();
8
9 if (isOnline) {
10 return null; // Nie pokazuj niczego gdy jest połączenie
11 }
12
13 return (
14 <div className="fixed top-0 left-0 right-0 bg-red-500 text-white text-center py-2 z-50">
15 <span className="text-sm font-medium">
16 📡 Brak połączenia z internetem - działasz w trybie offline
17 </span>
18 </div>
19 );
20}Push notifications pozwalają na ponowne zaangażowanie użytkowników:
1// lib/notifications.ts
2
3// Funkcja do konwersji VAPID key
4function urlBase64ToUint8Array(base64String: string) {
5 const padding = '='.repeat((4 - base64String.length % 4) % 4);
6 const base64 = (base64String + padding)
7 .replace(/-/g, '+')
8 .replace(/_/g, '/');
9
10 const rawData = window.atob(base64);
11 const outputArray = new Uint8Array(rawData.length);
12
13 for (let i = 0; i < rawData.length; ++i) {
14 outputArray[i] = rawData.charCodeAt(i);
15 }
16 return outputArray;
17}
18
19export async function subscribeToPushNotifications() {
20 if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
21 throw new Error('Push notifications are not supported');
22 }
23
24 const registration = await navigator.serviceWorker.ready;
25
26 // Sprawdź, czy już jest subskrypcja
27 let subscription = await registration.pushManager.getSubscription();
28
29 if (!subscription) {
30 const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
31 if (!vapidPublicKey) {
32 throw new Error('VAPID public key is not configured');
33 }
34
35 subscription = await registration.pushManager.subscribe({
36 userVisibleOnly: true,
37 applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
38 });
39 }
40
41 // Wyślij subskrypcję na serwer
42 await fetch('/api/notifications/subscribe', {
43 method: 'POST',
44 headers: {
45 'Content-Type': 'application/json',
46 },
47 body: JSON.stringify(subscription),
48 });
49
50 return subscription;
51}
52
53export async function unsubscribeFromPushNotifications() {
54 const registration = await navigator.serviceWorker.ready;
55 const subscription = await registration.pushManager.getSubscription();
56
57 if (subscription) {
58 await subscription.unsubscribe();
59
60 // Powiadom serwer o anulowaniu subskrypcji
61 await fetch('/api/notifications/unsubscribe', {
62 method: 'POST',
63 headers: {
64 'Content-Type': 'application/json',
65 },
66 body: JSON.stringify(subscription),
67 });
68 }
69}1// public/sw.js (dodatek do wcześniejszego kodu)
2
3// Obsługa push notifications
4self.addEventListener('push', (event) => {
5 const options = {
6 body: 'Masz nową wiadomość!',
7 icon: '/icons/icon-192x192.png',
8 badge: '/icons/badge-72x72.png',
9 vibrate: [100, 50, 100],
10 data: {
11 dateOfArrival: Date.now(),
12 primaryKey: 1
13 },
14 actions: [
15 {
16 action: 'explore',
17 title: 'Zobacz więcej',
18 icon: '/icons/checkmark.png'
19 },
20 {
21 action: 'close',
22 title: 'Zamknij',
23 icon: '/icons/xmark.png'
24 }
25 ]
26 };
27
28 if (event.data) {
29 const data = event.data.json();
30 options.body = data.body || options.body;
31 options.data = { ...options.data, ...data.data };
32 }
33
34 event.waitUntil(
35 self.registration.showNotification('Moja Aplikacja PWA', options)
36 );
37});
38
39// Obsługa kliknięcia w notification
40self.addEventListener('notificationclick', (event) => {
41 event.notification.close();
42
43 if (event.action === 'explore') {
44 // Otwórz określoną stronę
45 event.waitUntil(
46 clients.openWindow('/notifications')
47 );
48 } else if (event.action === 'close') {
49 // Zamknij powiadomienie
50 return;
51 } else {
52 // Domyślna akcja - otwórz główną stronę
53 event.waitUntil(
54 clients.openWindow('/')
55 );
56 }
57});1// app/api/notifications/send/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import webpush from 'web-push';
4import { prisma } from '@/lib/prisma';
5
6// Konfiguracja VAPID
7webpush.setVapidDetails(
8 'mailto:your-email@example.com',
9 process.env.VAPID_PUBLIC_KEY!,
10 process.env.VAPID_PRIVATE_KEY!
11);
12
13export async function POST(request: NextRequest) {
14 try {
15 const { title, body, url } = await request.json();
16
17 // Pobierz wszystkie subskrypcje z bazy danych
18 const subscriptions = await prisma.pushSubscription.findMany();
19
20 const payload = JSON.stringify({
21 title,
22 body,
23 url,
24 icon: '/icons/icon-192x192.png',
25 });
26
27 // Wyślij powiadomienia do wszystkich subskrypcji
28 const promises = subscriptions.map(async (subscription) => {
29 try {
30 await webpush.sendNotification(
31 {
32 endpoint: subscription.endpoint,
33 keys: {
34 p256dh: subscription.p256dh,
35 auth: subscription.auth,
36 },
37 },
38 payload
39 );
40 } catch (error) {
41 console.error('Error sending notification:', error);
42
43 // Jeśli subskrypcja jest nieważna, usuń ją
44 if (error.statusCode === 410) {
45 await prisma.pushSubscription.delete({
46 where: { id: subscription.id },
47 });
48 }
49 }
50 });
51
52 await Promise.all(promises);
53
54 return NextResponse.json({ success: true });
55 } catch (error) {
56 console.error('Error sending notifications:', error);
57 return NextResponse.json(
58 { error: 'Failed to send notifications' },
59 { status: 500 }
60 );
61 }
62}1// components/InstallPrompt.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5
6interface BeforeInstallPromptEvent extends Event {
7 readonly platforms: string[];
8 readonly userChoice: Promise<{
9 outcome: 'accepted' | 'dismissed';
10 platform: string;
11 }>;
12 prompt(): Promise<void>;
13}
14
15export default function InstallPrompt() {
16 const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
17 const [showInstallButton, setShowInstallButton] = useState(false);
18
19 useEffect(() => {
20 const handler = (e: Event) => {
21 // Zapobiegaj automatycznego wyświetlania promptu
22 e.preventDefault();
23 setDeferredPrompt(e as BeforeInstallPromptEvent);
24 setShowInstallButton(true);
25 };
26
27 window.addEventListener('beforeinstallprompt', handler);
28
29 // Sprawdź, czy aplikacja jest już zainstalowana
30 window.addEventListener('appinstalled', () => {
31 console.log('PWA została zainstalowana');
32 setShowInstallButton(false);
33 setDeferredPrompt(null);
34 });
35
36 return () => {
37 window.removeEventListener('beforeinstallprompt', handler);
38 };
39 }, []);
40
41 const handleInstallClick = async () => {
42 if (!deferredPrompt) return;
43
44 // Pokaż prompt instalacji
45 deferredPrompt.prompt();
46
47 // Poczekaj na wybór użytkownika
48 const { outcome } = await deferredPrompt.userChoice;
49
50 if (outcome === 'accepted') {
51 console.log('Użytkownik zaakceptował instalację');
52 } else {
53 console.log('Użytkownik odrzucił instalację');
54 }
55
56 setDeferredPrompt(null);
57 setShowInstallButton(false);
58 };
59
60 if (!showInstallButton) {
61 return null;
62 }
63
64 return (
65 <div className="fixed bottom-4 right-4 bg-blue-500 text-white p-4 rounded-lg shadow-lg">
66 <div className="flex items-center space-x-3">
67 <div>
68 <h3 className="font-semibold">Zainstaluj aplikację</h3>
69 <p className="text-sm opacity-90">Dodaj do ekranu głównego dla szybszego dostępu</p>
70 </div>
71 <div className="flex space-x-2">
72 <button
73 onClick={handleInstallClick}
74 className="bg-white text-blue-500 px-3 py-1 rounded text-sm font-medium"
75 >
76 Zainstaluj
77 </button>
78 <button
79 onClick={() => setShowInstallButton(false)}
80 className="text-white opacity-75 hover:opacity-100"
81 >
82 ✕
83 </button>
84 </div>
85 </div>
86 </div>
87 );
88}Lighthouse to narzędzie Google do audytu PWA. Możesz go uruchomić:
npm install -g lighthouseMicrosoft PWA Builder (https://www.pwabuilder.com/) to narzędzie do testowania i budowania PWA.
1// utils/pwa-test.ts
2export function testPWAFeatures() {
3 const results = {
4 serviceWorker: 'serviceWorker' in navigator,
5 pushNotifications: 'PushManager' in window,
6 notifications: 'Notification' in window,
7 backgroundSync: 'serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype,
8 installPrompt: true, // Wykrywane dynamicznie
9 offline: 'onLine' in navigator,
10 };
11
12 console.table(results);
13 return results;
14}Progressive Web Apps to potężna technologia, która pozwala tworzyć aplikacje webowe o funkcjonalności zbliżonej do aplikacji natywnych. Next.js z pakietem next-pwa lub własną implementacją Service Worker oferuje wszystkie narzędzia potrzebne do stworzenia w pełni funkcjonalnej PWA.
Kluczowe elementy implementacji PWA w Next.js:
PWA pozwala na dostarczenie lepszego doświadczenia użytkownika, zwiększenie zaangażowania i poprawę wydajności aplikacji, szczególnie w warunkach słabego połączenia internetowego.