Construire une Progressive Web App (PWA) avec Next.js App Router

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

Les Progressive Web Apps (PWA) combinent le meilleur du web et des applications natives : installation sur l'écran d'accueil, fonctionnement hors-ligne, notifications push et performances optimales. Dans ce tutoriel, vous allez transformer une application Next.js App Router en PWA complète, étape par étape.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé sur votre machine
  • Une connaissance de base de Next.js App Router et TypeScript
  • Un éditeur de code (VS Code recommandé)
  • Un navigateur compatible PWA (Chrome, Edge, Firefox)

Ce que vous allez construire

Une application Next.js qui :

  • S'installe comme une application native sur mobile et desktop
  • Fonctionne hors-ligne grâce au cache intelligent
  • Envoie des notifications push aux utilisateurs
  • Se synchronise automatiquement quand la connexion revient
  • Obtient un score Lighthouse PWA de 100

Étape 1 : Créer le projet Next.js

Commençons par initialiser un nouveau projet Next.js avec TypeScript :

npx create-next-app@latest my-pwa-app --typescript --tailwind --app --src-dir
cd my-pwa-app

Installez les dépendances nécessaires pour la PWA :

npm install next-pwa @ducanh2912/next-pwa
npm install -D webpack

Nous utilisons @ducanh2912/next-pwa qui est le fork maintenu activement et compatible avec Next.js 14+ et App Router.

Étape 2 : Configurer next-pwa

Modifiez votre fichier next.config.ts pour activer le support 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);

Cette configuration :

  • Génère le service worker dans le dossier public/
  • Active le cache agressif pour la navigation côté client
  • Recharge automatiquement quand la connexion revient
  • Désactive le service worker en développement pour éviter les conflits

Étape 3 : Créer le Web App Manifest

Le fichier manifest indique au navigateur comment afficher votre application une fois installée. Créez src/app/manifest.ts :

// src/app/manifest.ts
import type { MetadataRoute } from "next";
 
export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "Mon Application PWA",
    short_name: "MaPWA",
    description:
      "Une Progressive Web App construite avec 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"],
  };
}

Les icônes avec purpose: "maskable" sont essentielles pour Android. Elles permettent au système d'appliquer un masque adaptatif à votre icône pour qu'elle s'intègre harmonieusement avec les autres applications.

Étape 4 : Ajouter les métadonnées PWA

Mettez à jour votre layout racine pour inclure les métadonnées nécessaires :

// src/app/layout.tsx
import type { Metadata, Viewport } from "next";
import "./globals.css";
 
export const metadata: Metadata = {
  title: "Mon Application PWA",
  description: "Une PWA construite avec Next.js App Router",
  appleWebApp: {
    capable: true,
    statusBarStyle: "default",
    title: "MaPWA",
  },
  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="fr">
      <head>
        <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
      </head>
      <body className="antialiased">{children}</body>
    </html>
  );
}

Étape 5 : Générer les icônes PWA

Créez les icônes nécessaires. Vous pouvez utiliser un outil comme 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"

Ou créez manuellement les fichiers suivants :

public/
├── icons/
│   ├── icon-192x192.png
│   ├── icon-384x384.png
│   └── icon-512x512.png
├── screenshots/
│   ├── desktop.png
│   └── mobile.png
└── favicon.ico

Étape 6 : Implémenter le cache hors-ligne

Créez une stratégie de cache personnalisée. Ajoutez un fichier worker/index.ts :

// worker/index.ts
import { defaultCache } from "@ducanh2912/next-pwa/cache";
 
/** @type {import("@ducanh2912/next-pwa").RuntimeCaching[]} */
export const runtimeCaching = [
  // Cache des pages HTML avec stratégie Network First
  {
    urlPattern: /^https:\/\/.*\.html$/,
    handler: "NetworkFirst",
    options: {
      cacheName: "html-cache",
      expiration: {
        maxEntries: 32,
        maxAgeSeconds: 24 * 60 * 60, // 24 heures
      },
    },
  },
  // Cache des images avec stratégie Cache First
  {
    urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
    handler: "CacheFirst",
    options: {
      cacheName: "image-cache",
      expiration: {
        maxEntries: 64,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 jours
      },
    },
  },
  // Cache des polices Google
  {
    urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
    handler: "CacheFirst",
    options: {
      cacheName: "google-fonts-cache",
      expiration: {
        maxEntries: 16,
        maxAgeSeconds: 365 * 24 * 60 * 60, // 1 an
      },
    },
  },
  // Cache des appels API avec stratégie Stale While Revalidate
  {
    urlPattern: /^https:\/\/api\..*$/i,
    handler: "StaleWhileRevalidate",
    options: {
      cacheName: "api-cache",
      expiration: {
        maxEntries: 32,
        maxAgeSeconds: 60 * 60, // 1 heure
      },
    },
  },
  ...defaultCache,
];

Mettez à jour next.config.ts pour utiliser cette configuration :

// 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,
          },
        },
      },
      {
        urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
        handler: "CacheFirst",
        options: {
          cacheName: "google-fonts-cache",
          expiration: {
            maxEntries: 16,
            maxAgeSeconds: 365 * 24 * 60 * 60,
          },
        },
      },
    ],
  },
});
 
const nextConfig: NextConfig = {
  reactStrictMode: true,
};
 
export default withPWA(nextConfig);

Étape 7 : Créer une page hors-ligne

Quand l'utilisateur est hors-ligne et tente d'accéder à une page non mise en cache, affichez une page d'erreur conviviale :

// 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">
          Vous êtes hors-ligne
        </h1>
        <p className="mb-8 text-gray-400">
          Vérifiez votre connexion Internet et réessayez.
        </p>
        <button
          onClick={() => window.location.reload()}
          className="rounded-lg bg-blue-600 px-6 py-3 font-semibold
                     transition-colors hover:bg-blue-700"
        >
          Réessayer
        </button>
      </div>
    </div>
  );
}

La page /offline est automatiquement pré-cachée par next-pwa. Quand l'utilisateur perd la connexion, il est redirigé vers cette page au lieu de voir l'erreur par défaut du navigateur.

Étape 8 : Détecter le statut de connexion

Créez un hook personnalisé pour réagir aux changements de connexion :

// src/hooks/useOnlineStatus.ts
"use client";
 
import { useEffect, useState, 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; // Toujours en ligne côté serveur
}
 
export function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

Utilisez ce hook dans un composant bannière :

// 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">
      ⚠️ Vous êtes actuellement hors-ligne.
      Certaines fonctionnalités peuvent être limitées.
    </div>
  );
}

Étape 9 : Bouton d'installation personnalisé

Créez un bouton d'installation élégant qui apparaît quand le navigateur propose l'installation :

// 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(() => {
    // Vérifier si déjà installée
    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">
            Installer l'application
          </h3>
          <p className="text-sm text-gray-400">
            Accès rapide depuis votre écran d'accueil
          </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"
          >
            Plus tard
          </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"
          >
            Installer
          </button>
        </div>
      </div>
    </div>
  );
}

Étape 10 : Notifications Push

Implémentez les notifications push pour engager vos utilisateurs. Créez d'abord le composant côté client :

// 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),
      });
 
      // Envoyer l'abonnement à votre serveur
      await fetch("/api/push/subscribe", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(subscription),
      });
 
      setIsSubscribed(true);
      setPermission("granted");
    } catch (error) {
      console.error(
        "Erreur lors de l'abonnement aux notifications:",
        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"
    >
      🔔 Activer les notifications
    </button>
  );
}

Créez ensuite la route API pour gérer les abonnements :

// src/app/api/push/subscribe/route.ts
import { NextRequest, NextResponse } from "next/server";
 
// En production, stockez les abonnements dans une base de données
const subscriptions: PushSubscription[] = [];
 
export async function POST(request: NextRequest) {
  try {
    const subscription = await request.json();
    subscriptions.push(subscription);
 
    return NextResponse.json(
      { message: "Abonnement enregistré" },
      { status: 201 }
    );
  } catch (error) {
    return NextResponse.json(
      { error: "Erreur serveur" },
      { status: 500 }
    );
  }
}

Étape 11 : Synchronisation en arrière-plan

Implémentez la synchronisation des données quand la connexion revient :

// 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);
 
  // Enregistrer le sync si l'API est disponible
  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 {
      // Si ça échoue encore, on garde dans la file
      return;
    }
  }
 
  // Vider la file après succès
  const clearTx = db.transaction(STORE_NAME, "readwrite");
  clearTx.objectStore(STORE_NAME).clear();
}

Utilisez cette synchronisation dans vos formulaires :

// src/components/ContactForm.tsx
"use client";
 
import { useState } from "react";
import { useOnlineStatus } from "@/hooks/useOnlineStatus";
import { queueRequest } from "@/lib/backgroundSync";
 
export function ContactForm() {
  const isOnline = useOnlineStatus();
  const [status, setStatus] = useState<
    "idle" | "sending" | "sent" | "queued"
  >("idle");
 
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setStatus("sending");
 
    const formData = new FormData(e.currentTarget);
    const data = Object.fromEntries(formData);
 
    if (isOnline) {
      try {
        await fetch("/api/contact", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(data),
        });
        setStatus("sent");
      } catch {
        await queueRequest("/api/contact", "POST", data);
        setStatus("queued");
      }
    } else {
      await queueRequest("/api/contact", "POST", data);
      setStatus("queued");
    }
  };
 
  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <input
        name="email"
        type="email"
        required
        placeholder="votre@email.com"
        className="w-full rounded-lg border border-gray-700 bg-gray-800
                   px-4 py-3 text-white placeholder-gray-500"
      />
      <textarea
        name="message"
        required
        placeholder="Votre message..."
        rows={4}
        className="w-full rounded-lg border border-gray-700 bg-gray-800
                   px-4 py-3 text-white placeholder-gray-500"
      />
      <button
        type="submit"
        disabled={status === "sending"}
        className="w-full rounded-lg bg-blue-600 px-6 py-3
                   font-semibold text-white hover:bg-blue-700
                   disabled:opacity-50 transition-colors"
      >
        {status === "sending" && "Envoi en cours..."}
        {status === "idle" && "Envoyer"}
        {status === "sent" && "✅ Message envoyé"}
        {status === "queued" &&
          "📥 Message en file (envoi automatique au retour en ligne)"}
      </button>
    </form>
  );
}

Étape 12 : Assembler le tout

Mettez à jour votre page d'accueil pour intégrer tous les composants :

// 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">
            Mon Application PWA
          </h1>
          <p className="mb-8 text-xl text-gray-400">
            Installable, hors-ligne, rapide.
          </p>
 
          <div className="mb-12 grid gap-6 md:grid-cols-3">
            <FeatureCard
              icon="📱"
              title="Installable"
              description="Ajoutez l'app à votre écran d'accueil"
            />
            <FeatureCard
              icon="🔌"
              title="Hors-ligne"
              description="Fonctionne sans connexion Internet"
            />
            <FeatureCard
              icon="🔔"
              title="Notifications"
              description="Restez informé en temps réel"
            />
          </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">
              Contactez-nous
            </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>
  );
}

Tester votre PWA

Test local

Lancez un build de production pour tester le service worker :

npm run build
npm start

Le service worker ne fonctionne pas en mode développement (npm run dev). Vous devez toujours tester avec un build de production.

Audit Lighthouse

  1. Ouvrez Chrome DevTools (F12)
  2. Allez dans l'onglet Lighthouse
  3. Sélectionnez Progressive Web App
  4. Lancez l'audit

Votre application devrait obtenir un score proche de 100 si tous les critères sont respectés.

Vérifications manuelles

Testez les scénarios suivants :

  • Installation : cliquez sur l'icône d'installation dans la barre d'adresse
  • Hors-ligne : activez le mode avion et naviguez dans l'application
  • Cache : visitez des pages, passez hors-ligne, puis revisitez-les
  • Notifications : activez les notifications et envoyez un test

Ajouter au fichier .gitignore

Le service worker et les fichiers de cache générés ne doivent pas être versionnés :

# 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

Dépannage

Le service worker ne se met pas à jour

Videz le cache dans Chrome DevTools, onglet Application, puis section Service Workers, et cliquez sur "Unregister". Rechargez ensuite la page.

L'invite d'installation n'apparaît pas

L'événement beforeinstallprompt ne se déclenche que si :

  • La page est servie en HTTPS (ou localhost)
  • Le manifest est valide avec les champs requis
  • Un service worker est enregistré
  • L'utilisateur n'a pas déjà installé l'application

Les notifications ne fonctionnent pas

Vérifiez que :

  • Les clés VAPID sont correctement configurées
  • Le service worker est actif
  • L'utilisateur a accordé la permission
  • Le navigateur supporte l'API Push

Prochaines étapes

  • Ajoutez le partage natif avec l'API Web Share
  • Implémentez le mode sombre adaptatif avec prefers-color-scheme
  • Explorez les Periodic Background Sync pour des mises à jour régulières
  • Intégrez Workbox pour des stratégies de cache avancées
  • Ajoutez le support des raccourcis d'application dans le manifest

Conclusion

Vous avez transformé une application Next.js en Progressive Web App complète. Votre application est maintenant installable sur tous les appareils, fonctionne hors-ligne grâce au cache intelligent, et peut envoyer des notifications push pour engager vos utilisateurs.

Les PWA représentent le meilleur compromis entre applications web et natives : une seule base de code, un déploiement instantané via URL, et des capacités natives. Avec Next.js App Router et les outils modernes, créer une PWA n'a jamais été aussi accessible.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Boostez vos applications web : Guide du débutant pour le SDK JavaScript Voice de Twilio.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes