Setting up a PWA in Next.js

How-toPWANext.js

The following details how to set up a PWA in a Next.js 15 App Router project. As well as adding an install button.

Progressive Web Apps (PWAs) combine the best of web and native applications. They're websites that can be installed on devices like regular apps, work offline, and send push notifications.

PWAs have some benefits

They're installable - Users can add your web app to their home screen; Offline Support Works without an internet connection; They Feels like a native app with full-screen mode;

This post will walk through setting up a PWA in Next.js. And how to create a custom install button. Like the one below:

Step 1: Create the manifest.json File

The manifest.json file provides metadata for your PWA, like its name, icons, and theme color.

In the root of your public/ folder, create a manifest.json file:

// public/manifest.json
{
  "name": "Your App Name",
  "short_name": "App",
  "description": "Your app description",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#ffffff",
  "icons": [
    {
      "src": "/icons/48x48.png",
      "sizes": "48x48",
      "type": "image/png"
    },
    {
      "src": "/icons/72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/256x256.png",
      "sizes": "256x256",
      "type": "image/png"
    },
    {
      "src": "/icons/512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

Add Icons: Place the required icon files (e.g., 192x192.png, 512x512.png, etc.) in the public/icons directory.

Step 2: Update the layout.tsx to Include Manifest and Meta Tags

In a Next.js App Router project, use layout.tsx in the app directory to add the necessary tags.

Open your app/layout.tsx file and add the manifest link, theme color, and icon link in the <head> section.

// app/layout.tsx
import './globals.css';
import { Metadata } from 'next';
 
export const metadata: Metadata = {
  title: 'Your App Name',
  description: 'Your app description',
  themeColor: '#ffffff',
};
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <head>
        <link rel="manifest" href="/manifest.json" />
        <link rel="icon" href="/icons/192x192.png" />
        <meta name="theme-color" content="#ffffff" />
      </head>
      <body>{children}</body>
    </html>
  );
}

Step 3: Add an Install Button for the PWA

This will allow users to manually trigger the PWA install prompt when they click a button.

Create an InstallButton component that listens for the beforeinstallprompt event, which is triggered when the app meets the PWA installability criteria.

// components/PWAInstallButton.tsx
"use client";
 
import { useEffect, useState } from "react";
import Button from "../ui/button";
 
export default function PWAInstallButton() {
  const [deferredPrompt, setDeferredPrompt] = useState(null);
  const [isInstallable, setIsInstallable] = useState(false);
 
  useEffect(() => {
    const handleBeforeInstallPrompt = (e) => {
      e.preventDefault();
      setDeferredPrompt(e);
      setIsInstallable(true);
    };
 
    window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
 
    return () =>
      window.removeEventListener(
        "beforeinstallprompt",
        handleBeforeInstallPrompt,
      );
  }, []);
 
  const handleInstallClick = async () => {
    if (deferredPrompt) {
      deferredPrompt.prompt();
      const { outcome } = await deferredPrompt.userChoice;
      setDeferredPrompt(null);
      setIsInstallable(false);
      console.log(`User response to the install prompt: ${outcome}`);
    }
  };
 
  return (
    <div className="flex">
      {isInstallable && (
        <Button onClick={handleInstallClick}>Install App</Button>
      )}
    </div>
  );
}

Add PWAInstallButton to your layout or any component where you'd like the install button to appear.

// app/layout.tsx or another component
import PWAInstallButton from '@/components/PWAInstallButton';
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <link rel="manifest" href="/manifest.json" />
        <meta name="theme-color" content="#ffffff" />
      </head>
      <body>
        <PWAInstallButton />
        {children}
      </body>
    </html>
  );
}

Important Caveat: Client-Side Navigation and Install Button

When implementing the install button, there's an important caveat to consider: the beforeinstallprompt event only fires once when the site initially loads. This creates a problem if your install button is on a page that isn't the user's entry point to your app.

For example:

  • If a user lands directly on /blog/pwa-post where your install button is, it works fine
  • But if they first land on / (home page) and then navigate to /blog/pwa-post, the install button won't work because the beforeinstallprompt event already fired and was missed

To solve this, we need to capture the event at the application level using a global handler:

// utils/pwaHandler.js
let deferredPrompt = null;
 
export const getPrompt = () => deferredPrompt;
export const clearPrompt = () => {
  deferredPrompt = null;
};
 
if (typeof window !== "undefined") {
  window.addEventListener("beforeinstallprompt", (e) => {
    e.preventDefault();
    deferredPrompt = e;
  });
}

First, initialize the handler in your root layout:

// app/layout.tsx
import "@/utils/pwaHandler";
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>{children}</body>
    </html>
  );
}

Then use it in your install button component:

// components/PWAInstallButton.tsx
import { getPrompt, clearPrompt } from "@/utils/pwaHandler";
 
export default function PWAInstallButton() {
  const [isInstallable, setIsInstallable] = useState(false);
 
  useEffect(() => {
    // Check if installation is possible
    const prompt = getPrompt();
    setIsInstallable(!!prompt);
  }, []);
 
  const handleInstallClick = async () => {
    const promptEvent = getPrompt();
    if (promptEvent) {
      promptEvent.prompt();
      const { outcome } = await promptEvent.userChoice;
      clearPrompt();
      setIsInstallable(false);
    }
  };
 
  if (!isInstallable) return null;
 
  return <Button onClick={handleInstallClick}>Install App</Button>;
}

This approach ensures the install button works correctly regardless of how users navigate to it within your app.

Step 4: Set Up the Service Worker (Optional)

To further optimize your PWA for offline capabilities, you can set up a service worker using next-pwa or another library. Here's an example using next-pwa:

Install next-pwa:

npm install next-pwa

Configure next-pwa in your next.config.js file:

// next.config.js
const withPWA = require("next-pwa")({
  dest: "public",
});
 
module.exports = withPWA({
  // other Next.js configurations
});

After setting up next-pwa, a sw.js file will be generated automatically in the public/ directory during the build process. This file enables caching for offline use.

Step 5: Add Generated Service Worker Files to .gitignore

Since service workers like sw.js and workbox-xxxxxx.js are generated during the build process, add them to .gitignore:

# .gitignore
/public/sw.js
/public/workbox-*.js

Step 6: Test the PWA Setup

Build and serve the app in production mode:

npm run build && npm start

Open your app in Chrome or another browser that supports PWAs, and check for the "Install" option in the browser's address bar or try the custom install button.

Test the offline functionality by going offline after the initial load to see if the app is available.

Browser Support

Not all browsers support PWA installation in the same way:

Chrome, Edge, and other Chromium-based browsers: Full PWA support with the install prompt

Safari (iOS and macOS): No native install prompt, but users can manually add the site to their home screen:

  1. Click the share button (box with arrow) in the browser toolbar
  2. Select "Add to Home Screen"
  3. Customize the name and click "Add"

Firefox: Limited PWA support, varies by platform

See caniuse.com/install-app for the full list.

© 2025 cdnkr