---
title: "Next.js 16 PWA: Convert Your App in 10 Minutes"
slug: "turn-nextjs-16-app-into-pwa"
published: "2025-11-03"
updated: "2026-03-28"
validated: "2026-03-27"
categories:
  - "Next.js"
tags:
  - "nextjs 16 pwa"
  - "progressive web apps"
  - "pwa push notifications"
  - "build pwa nextjs"
  - "service worker"
  - "web app manifest"
  - "offline pwa"
  - "troubleshooting notifications"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js"
  - "typescript"
  - "web-push"
  - "imagemagick"
status: "stable"
llm-purpose: "Learn how to transform your Next.js 16 app into a powerful PWA with this detailed guide. Boost user engagement via push notifications!"
llm-prereqs:
  - "Access to Next.js"
  - "Access to TypeScript"
  - "Access to web-push"
  - "Access to ImageMagick"
llm-outputs:
  - "Completed outcome: Learn how to transform your Next.js 16 app into a powerful PWA with this detailed guide. Boost user engagement via push notifications!"
---

**Summary Triples**
- (Transform Your Next.js 16 App into a Powerful PWA, focuses-on, Learn how to transform your Next.js 16 app into a powerful PWA with this detailed guide. Boost user engagement via push notifications!)
- (Transform Your Next.js 16 App into a Powerful PWA, category, general)

### {GOAL}
Learn how to transform your Next.js 16 app into a powerful PWA with this detailed guide. Boost user engagement via push notifications!

### {PREREQS}
- Access to Next.js
- Access to TypeScript
- Access to web-push
- Access to ImageMagick

### {STEPS}
1. Create the Web App Manifest
2. Generate VAPID Keys for Web Push
3. Install Necessary Dependencies
4. Create the Service Worker
5. Create Server Actions for Push Notifications
6. Integrate Notifications Into Your App
7. Testing Your PWA
8. Deploying to Production

<!-- llm:goal="Learn how to transform your Next.js 16 app into a powerful PWA with this detailed guide. Boost user engagement via push notifications!" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to web-push" -->
<!-- llm:prereq="Access to ImageMagick" -->
<!-- llm:output="Completed outcome: Learn how to transform your Next.js 16 app into a powerful PWA with this detailed guide. Boost user engagement via push notifications!" -->

# Next.js 16 PWA: Convert Your App in 10 Minutes
> Next.js 16 PWA setup: add manifest, service worker, and push notifications. Convert your app to installable PWA with offline support in 10 minutes.
Matija Žiberna · 2025-11-03

I was building a time tracking application for delivery drivers when I realized the core problem: employees needed a native app experience on their phones, home screen installation, persistent notifications, offline capability—but we didn't have resources to build separate iOS and Android apps. That's when I discovered that converting our existing Next.js web application into a PWA solved the entire problem. A year later, we've deployed this to hundreds of drivers, and it performs just as well as a native app.

This guide walks you through exactly how to turn your Next.js 16 application into a production-ready PWA with Web Push notifications. By the end, you'll understand the complete picture: from manifest files to service workers to push subscription management.

## Why PWAs Matter for Business Applications

A Progressive Web Application gives your business app several advantages. When your employees install the PWA on their home screen, it launches in standalone mode—no browser chrome, no address bar—so it looks and feels like a native app. Second, PWAs enable push notifications that persist on the phone's home screen, just like native apps, keeping users engaged without requiring them to check the app actively. Third, there's no app store bottleneck: you deploy updates to your server and users get the latest version on their next launch, with no waiting for review processes.

## Manual PWA Setup vs. Libraries

In 2026, the single biggest advantage of a manual PWA setup with Next.js 16 is **end-to-end control**: you own the manifest, service worker, caching, and push logic without an extra library or webpack plugin. This makes it easier to align with current best practices (like `manifest.ts`, `updateViaCache: "none"`, and strict SW headers) and to debug and adapt as browsers evolve—especially with Chrome's quieter permission prompts, automatic permission revocations, and the continued need for precise error handling around notifications and push subscriptions.

Libraries like next-pwa and Serwist are excellent for projects that need advanced caching strategies and faster setup, but they hide the underlying APIs behind abstraction layers. A manual approach keeps everything transparent and gives you direct control over behavior when things go wrong.

## Quick Reference: What You'll Build

| Aspect | Details |
|--------|----------|
| **Setup time** | ~20 minutes |
| **Requirements** | HTTPS (or localhost), Next.js 16, web-push CLI |
| **What you'll get** | Manifest, Service Worker, VAPID keys, push subscription flow |
| **When to use this guide** | You want full control, to learn the Web APIs deeply, and to debug issues yourself |
| **When to use next-pwa/Serwist** | You need advanced caching strategies faster or prefer library abstractions |

---

## Step 1: Create the Web App Manifest

A web app manifest is a JSON file that tells the browser how to display your PWA. It controls the app name, icons, colors, and startup behavior.

Create a new file at `src/app/manifest.ts`:

```typescript
// File: src/app/manifest.ts
import type { MetadataRoute } from 'next'

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'Boneks',
    short_name: 'Boneks',
    description: 'Time tracking and delivery management for mobile teams',
    start_url: 'https://www.yourdomain.com/admin',
    scope: 'https://www.yourdomain.com/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#10b981',
    orientation: 'portrait-primary',
    icons: [
      {
        src: '/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
        purpose: 'any',
      },
      {
        src: '/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
        purpose: 'any',
      },
      {
        src: '/icon-192x192-maskable.png',
        sizes: '192x192',
        type: 'image/png',
        purpose: 'maskable',
      },
    ],
  }
}
```

The key properties: `display: 'standalone'` makes the app launch without browser UI, `start_url` points to where users land when opening from home screen, and `icons` are what appear on the home screen (generate these using a tool like [favicon generator](https://realfavicongenerator.net/)).

Next.js 16 automatically links this manifest to your HTML via the special route handler convention—you don't need to manually add any link tags.

## Step 2: Generate VAPID Keys for Web Push

Web Push notifications require authentication between your server and the push service. This is where VAPID (Voluntary Application Server Identification) keys come in. They're a pair of public and private keys that identify your application to push services like FCM and APNs.

Generate them using the web-push CLI:

```bash
npm install -g web-push
web-push generate-vapid-keys
```

This outputs something like:

```
Public Key:
BJJ1YuYqWLHm0blexdUaZ1yAT7PGuEgWxVbbR-XB7RsNoNCg6ljkTUrC1XXwN_1j6ybqMB8ZrkhQODCtfjcRvXo

Private Key:
39i_a0W-qGR6HejMHtsvHcsx2CQnWrV_OS7u7otL9SI
```

Add both to your `.env` file:

```
NEXT_PUBLIC_VAPID_PUBLIC_KEY=BJJ1YuYqWLHm0blexdUaZ1yAT7PGuEgWxVbbR-XB7RsNoNCg6ljkTUrC1XXwN_1j6ybqMB8ZrkhQODCtfjcRvXo
VAPID_PRIVATE_KEY=39i_a0W-qGR6HejMHtsvHcsx2CQnWrV_OS7u7otL9SI
```

The public key is prefixed with `NEXT_PUBLIC_` because it needs to be accessible in the browser. The private key stays secret on your server. These keys ensure that only your server can send notifications to your app, preventing malicious third parties from impersonating your application.

## Step 3: Install Dependencies

You'll need two packages to handle Web Push:

```bash
npm install web-push
npm install -D @types/web-push
```

The `web-push` package handles sending notifications from your server, and `@types/web-push` provides TypeScript definitions.

## Step 2: Generate VAPID Keys for Web Push

Web Push notifications require authentication between your server and the push service. This is where VAPID (Voluntary Application Server Identification) keys come in. They're a pair of public and private keys that identify your application to push services like FCM and APNs.

Generate them using the web-push CLI:

```bash
npm install -g web-push
web-push generate-vapid-keys
```

This outputs something like:

```
Public Key:
BJJ1YuYqWLHm0blexdUaZ1yAT7PGuEgWxVbbR-XB7RsNoNCg6ljkTUrC1XXwN_1j6ybqMB8ZrkhQODCtfjcRvXo

Private Key:
39i_a0W-qGR6HejMHtsvHcsx2CQnWrV_OS7u7otL9SI
```

Add both to your `.env` file:

```
NEXT_PUBLIC_VAPID_PUBLIC_KEY=BJJ1YuYqWLHm0blexdUaZ1yAT7PGuEgWxVbbR-XB7RsNoNCg6ljkTUrC1XXwN_1j6ybqMB8ZrkhQODCtfjcRvXo
VAPID_PRIVATE_KEY=39i_a0W-qGR6HejMHtsvHcsx2CQnWrV_OS7u7otL9SI
```

The public key is prefixed with `NEXT_PUBLIC_` because it needs to be accessible in the browser. The private key stays secret on your server. These keys ensure that only your server can send notifications to your app, preventing malicious third parties from impersonating your application.

## Step 3: Install Dependencies

You'll need two packages to handle Web Push:

```bash
npm install web-push
npm install -D @types/web-push
```

The `web-push` package handles sending notifications from your server, and `@types/web-push` provides TypeScript definitions.

## Step 4: Create the Service Worker

A Service Worker is JavaScript that runs in the background, separate from your main app thread. It intercepts push events from the notification service and displays them to the user. The registration includes `updateViaCache: 'none'` to force the browser to always fetch the latest version—critical for ensuring users get bug fixes and new features immediately (not from stale HTTP cache).

Create `public/sw.js`:

```javascript
// File: public/sw.js
self.addEventListener('push', function (event) {
  if (event.data) {
    const data = event.data.json()
    const options = {
      body: data.body,
      icon: '/icon-192x192.png',
      badge: '/icon-192x192.png',
      tag: 'app-notification',
      vibrate: [100, 50, 100],
      requireInteraction: data.persistent || false,
      data: {
        dateOfArrival: Date.now(),
        url: data.url || '/',
      },
      actions: [
        {
          action: 'open',
          title: 'Open App',
        },
        {
          action: 'close',
          title: 'Dismiss',
        },
      ],
    }

    event.waitUntil(
      self.registration.showNotification(data.title || 'Notification', options)
    )
  }
})

self.addEventListener('notificationclick', function (event) {
  event.notification.close()

  if (event.action === 'close') {
    return
  }

  const baseUrl = 'https://www.yourdomain.com'
  const urlToOpen = event.notification.data.url
    ? baseUrl + event.notification.data.url
    : baseUrl + '/admin'

  event.waitUntil(
    clients.matchAll({
      type: 'window',
      includeUncontrolled: true,
    }).then(function (windowClients) {
      // Check if app is already open
      for (let i = 0; i < windowClients.length; i++) {
        const client = windowClients[i]
        if (client.url.includes('yourdomain.com') && 'focus' in client) {
          return client.focus()
        }
      }
      // If not open, open the app
      if (clients.openWindow) {
        return clients.openWindow(urlToOpen)
      }
    })
  )
})

self.addEventListener('notificationclose', function (event) {
  console.log('Notification dismissed:', event)
})
```

Here's what each part does:

- **Push event listener**: receives incoming push messages, extracts notification data, and calls `showNotification`. The `requireInteraction: true` property (when `persistent` is set) keeps the notification on screen until the user explicitly dismisses it—essential for business-critical alerts.
- **Notification click handler**: tries to focus an existing app window if one is open; if not, opens a new window. This prevents duplicate windows and creates a seamless UX.
- **updateViaCache**: set to 'none' ensures the browser always fetches the latest Service Worker script, bypassing HTTP cache entirely, so users always get the latest code.

Here's what each part does. The `push` event listener receives incoming push messages from your server. It extracts the notification data and calls `showNotification` with styling options. The `requireInteraction: true` property (when `persistent` is set) means the notification stays on screen until the user explicitly dismisses it—perfect for business-critical alerts.

When a user clicks the notification, the `notificationclick` handler fires. It tries to focus an existing app window if one is already open. If not, it opens a new window. This prevents duplicate windows and creates a seamless user experience.

## Step 5: Create Server Actions for Push Notifications

Your server needs to manage subscriptions and send notifications. Create a Server Action file at `src/app/push-actions.ts`:

```typescript
// File: src/app/push-actions.ts
'use server'

import webpush from 'web-push'

// Configure VAPID details
webpush.setVapidDetails(
  'mailto:noreply@yourdomain.com',
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
)

interface WebPushSubscription {
  endpoint: string
  keys: {
    p256dh: string
    auth: string
  }
}

// In-memory storage (use a database in production)
let subscriptions: Set<WebPushSubscription> = new Set()

export async function subscribeToPush(sub: Record<string, unknown>) {
  try {
    const subscription: WebPushSubscription = {
      endpoint: sub.endpoint as string,
      keys: {
        p256dh: (sub.keys as Record<string, string>).p256dh,
        auth: (sub.keys as Record<string, string>).auth,
      },
    }
    subscriptions.add(subscription)
    console.log('User subscribed to push notifications')
    return { success: true, message: 'Subscribed to notifications' }
  } catch (error) {
    console.error('Error subscribing to push:', error)
    return { success: false, message: 'Failed to subscribe' }
  }
}

/**
 * Error handling guide:
 * - If error message includes "permission denied" or "NotAllowedError",
 *   the user denied notification permission in the browser.
 * - If error mentions "no active Service Worker", ensure the SW is fully
 *   registered and activated before calling subscribeToPush.
 * - If error references VAPID or push service, check that VAPID keys are set
 *   correctly in environment variables and are valid Base64 strings.
 */

export async function unsubscribeFromPush(sub: Record<string, unknown>) {
  try {
    const endpoint = sub.endpoint as string
    subscriptions.forEach((subscription) => {
      if (subscription.endpoint === endpoint) {
        subscriptions.delete(subscription)
      }
    })
    console.log('User unsubscribed from push notifications')
    return { success: true, message: 'Unsubscribed from notifications' }
  } catch (error) {
    console.error('Error unsubscribing from push:', error)
    return { success: false, message: 'Failed to unsubscribe' }
  }
}

export async function sendNotificationToAll(
  notificationData: {
    title: string
    body: string
    url?: string
    persistent?: boolean
  }
) {
  try {
    if (subscriptions.size === 0) {
      return { success: false, message: 'No active subscriptions' }
    }

    const notification = {
      title: notificationData.title,
      body: notificationData.body,
      icon: '/icon-192x192.png',
      url: notificationData.url || '/admin',
      persistent: notificationData.persistent || false,
    }

    const promises = Array.from(subscriptions).map((subscription) =>
      webpush
        .sendNotification(subscription, JSON.stringify(notification))
        .catch((error) => {
          console.error('Error sending notification:', error)
          subscriptions.delete(subscription)
        })
    )

    await Promise.all(promises)
    console.log(`Notification sent to ${subscriptions.size} users`)
    return { success: true, message: 'Notification sent' }
  } catch (error) {
    console.error('Error sending notification:', error)
    return { success: false, message: 'Failed to send notification' }
  }
}
```

This file serves two purposes. First, it manages subscriptions—storing which users have opted in to notifications. In a real application, you'd store these in a database (PostgreSQL, MongoDB, etc.). Second, it provides a function to send notifications to all subscribed users. The `webpush.sendNotification` function handles the actual transmission to the push service.

This file serves two purposes. First, it manages subscriptions—storing which users have opted in to notifications. In a real application, you'd store these in a database (PostgreSQL, MongoDB, etc.). Second, it provides a function to send notifications to all subscribed users. The `webpush.sendNotification` function handles the actual transmission to the push service.

## Step 6: Create a Push Notification Hook

Your client-side components need to subscribe to notifications. Create a hook at `src/lib/hooks/use-push-notifications.ts`:

```typescript
// File: src/lib/hooks/use-push-notifications.ts
'use client'

import { useCallback, useEffect, useState } from 'react'
import {
  subscribeToPush,
  unsubscribeFromPush,
  sendNotificationToAll,
} from '@/app/push-actions'

export function usePushNotifications() {
  const [isSupported, setIsSupported] = useState(false)
  const [isSubscribed, setIsSubscribed] = useState(false)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [subscription, setSubscription] = useState<PushSubscription | null>(null)

  // Check if push notifications are supported
  useEffect(() => {
    if (typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window) {
      setIsSupported(true)
      registerServiceWorker()
    }
  }, [])

  const registerServiceWorker = useCallback(async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
        updateViaCache: 'none',
      })

      const sub = await registration.pushManager.getSubscription()
      if (sub) {
        setSubscription(sub)
        setIsSubscribed(true)
      }
    } catch (err) {
      console.error('Error registering service worker:', err)
      setError('Failed to register service worker')
    }
  }, [])

  const subscribe = useCallback(async () => {
    try {
      setIsLoading(true)
      setError(null)

      // Always check current permission state before requesting
      if (Notification.permission === 'denied') {
        throw new Error('Notifications are blocked. Enable in browser settings.')
      }

      const permission = await Notification.requestPermission()
      if (permission !== 'granted') {
        throw new Error('Notification permission denied')
      }

      const registration = await navigator.serviceWorker.ready
      
      // Verify VAPID public key is set
      if (!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) {
        throw new Error('VAPID public key not configured')
      }

      const sub = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
          process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
        ),
      })

      const plainSub = JSON.parse(JSON.stringify(sub))
      const result = await subscribeToPush(plainSub)

      if (result.success) {
        setSubscription(sub)
        setIsSubscribed(true)
      } else {
        throw new Error(result.message)
      }
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Unknown error'
      setError(message)
      // Log full error details for debugging
      if (err instanceof Error) {
        console.error('Subscription error:', err.name, err.message)
      } else {
        console.error('Error subscribing:', err)
      }
    } finally {
      setIsLoading(false)
    }
  }, [])

  const unsubscribe = useCallback(async () => {
    try {
      setIsLoading(true)
      if (subscription) {
        const plainSub = JSON.parse(JSON.stringify(subscription))
        await unsubscribeFromPush(plainSub)
        await subscription.unsubscribe()
        setSubscription(null)
        setIsSubscribed(false)
      }
    } catch (err) {
      console.error('Error unsubscribing:', err)
    } finally {
      setIsLoading(false)
    }
  }, [subscription])

  return {
    isSupported,
    isSubscribed,
    isLoading,
    error,
    subscribe,
    unsubscribe,
  }
}

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
}
```

This hook handles the entire subscription lifecycle. When the component mounts, it attempts to register the Service Worker. The `subscribe` function requests the user's permission and creates a push subscription. The hook returns the current state so your components can react to subscription changes.

This hook handles the entire subscription lifecycle. When the component mounts, it attempts to register the Service Worker. The `subscribe` function requests the user's permission and creates a push subscription. The hook returns the current state so your components can react to subscription changes.

## Step 7: Integrate Notifications Into Your App

Now connect the notifications to your actual business logic. Here's an example from a time tracking component:

```typescript
// File: src/app/(admin)/time-tracking/timer-component.tsx
'use client'

import { useState } from 'react'
import { usePushNotifications } from '@/lib/hooks/use-push-notifications'
import { sendNotificationToAll } from '@/app/push-actions'

export function TimerComponent() {
  const pushNotifications = usePushNotifications()
  const [isRunning, setIsRunning] = useState(false)

  const handleStartTimer = async () => {
    setIsRunning(true)

    // Gracefully handle when push notifications aren't supported
    if (!pushNotifications.isSupported) {
      console.warn('Push notifications not supported in this browser')
      return
    }

    // Subscribe if not already subscribed
    if (!pushNotifications.isSubscribed) {
      await pushNotifications.subscribe()
    }

    // Send notification to all users
    if (pushNotifications.isSubscribed) {
      await sendNotificationToAll({
        title: 'Time Tracking Started',
        body: 'Your shift has started. Tap to view details.',
        url: '/admin/time-tracking',
        persistent: true,
      })
    }
  }

  const handleStopTimer = async () => {
    setIsRunning(false)

    // Send stop notification
    if (pushNotifications.isSubscribed) {
      await sendNotificationToAll({
        title: 'Shift Complete',
        body: 'Your shift has ended. View your summary.',
        url: '/admin/time-tracking',
        persistent: false,
      })
    }
  }

  return (
    <div>
      <button
        onClick={handleStartTimer}
        disabled={isRunning}
      >
        Start Tracking
      </button>
      <button
        onClick={handleStopTimer}
        disabled={!isRunning}
      >
        Stop Tracking
      </button>
    </div>
  )
}
```

The key insight here is that you control when notifications are sent. In a business app, you'd typically trigger notifications from your backend when significant events occur—a new delivery assignment arrives, a shift is about to end, an order is ready for pickup. The `persistent: true` flag ensures critical notifications stay on screen until the user acknowledges them.

The key insight here is that you control when notifications are sent. In a business app, you'd typically trigger notifications from your backend when significant events occur—a new delivery assignment arrives, a shift is about to end, an order is ready for pickup. The `persistent: true` flag ensures critical notifications stay on screen until the user acknowledges them.

## Step 8: Add Security Headers

Your Service Worker should never be cached, otherwise users won't get critical updates. Add security headers to your `next.config.ts`:

```typescript
// File: next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  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'",
          },
        ],
      },
    ]
  },
}

export default nextConfig
```

The critical header is `Cache-Control: no-cache, no-store, must-revalidate` on the Service Worker file. This forces browsers to always fetch the latest version, ensuring your users get bug fixes and new features immediately.

## Testing Your PWA

To test locally, you need HTTPS because browsers only allow Service Workers and push notifications over secure connections:

```bash
npm run dev -- --experimental-https
```

This generates a self-signed certificate for local development. Your app will be available at `https://localhost:3000` (or your configured port).

**Testing checklist:**
1. Open Chrome DevTools → **Application** tab → **Service Workers**. You should see your service worker registered with status "activated and running."
2. Check the **Network** tab and filter for requests to `/sw.js`. Verify the response headers include:
   - `Content-Type: application/javascript; charset=utf-8`
   - `Cache-Control: no-cache, no-store, must-revalidate`
3. Accept the notification permission prompt when prompted.
4. Check the **Console** for any errors (NotAllowedError, DOMException, etc.).
5. Install the app to your home screen (Chrome: three-dot menu → "Install app" or "Add to Home screen").
6. Launch the app from home screen and verify it opens in standalone mode (no address bar).
7. Send a test notification via your `sendNotificationToAll` function and verify it appears on the device.

## Troubleshooting: Push Notifications & Permissions

Push and notification errors can be tricky because the browser, permission system, and push service each have their own failure modes. Here's how to diagnose and fix the most common issues.

### NotAllowedError: User Denied Permission

**What it means:** The browser or user denied the notification permission request.

**Typical causes:**
- User clicked "Block" on the permission prompt
- Notification.permission is already "denied", so requestPermission() fails immediately
- Chrome's "quieter messaging" mode is active: instead of a prompt, you see only a bell icon in the address bar. The user may never see a permission request
- Chrome automatically blocks prompts for sites with low notification acceptance rates

**How to recover:**
- Always check `Notification.permission` before calling `requestPermission()`:
  ```typescript
  if (Notification.permission === 'denied') {
    // Show in-app message: "Notifications are blocked. To enable them, 
    // click the lock icon in the address bar and allow Notifications."
    return
  }
  ```
- Only call `requestPermission()` after a user action (button click) and after you've explained the value (e.g., "Turn on shift alerts").
- Once permission is "denied", the user must manually unblock it via browser settings; your code cannot re-prompt.

### Failed to Subscribe: DOMException

**What it means:** The push manager couldn't create a subscription.

**Common error messages:**
- "Registration failed - permission denied" — Notification.permission is "denied"; same as above
- "Registration failed - push service error" — VAPID keys misconfigured or push service unreachable
- "Subscription failed - no active Service Worker" — the SW isn't fully activated yet
- "Invalid applicationServerKey" — VAPID public key is not valid Base64 or was not converted to Uint8Array

**How to diagnose:**
1. Check that VAPID keys are set:
   ```bash
   echo $NEXT_PUBLIC_VAPID_PUBLIC_KEY  # Should be a URL-safe Base64 string
   ```
2. Verify the public key is 65 bytes when decoded from Base64 (P-256 curve):
   ```typescript
   const decoded = urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY)
   console.log('Key length:', decoded.length) // Should be 65
   ```
3. Check that the Service Worker is fully active:
   ```typescript
   const registration = await navigator.serviceWorker.ready
   console.log('Active SW:', registration.active) // Should not be null
   ```
4. Ensure you're on HTTPS or localhost (not http).
5. Check the browser console for the exact error message and log it to your server for diagnostics.

**How to recover:**
- Validate VAPID configuration on both client and server
- Ensure the Service Worker is active before attempting subscription; wait for `navigator.serviceWorker.ready` if needed
- Wrap the subscribe call in a try/catch and log the error.name and error.message to help diagnose

### Service Worker Registration Failed

**What it means:** The browser couldn't register `/sw.js` as a service worker.

**Common causes:**
- **Not HTTPS:** Service Workers require a secure context (HTTPS or localhost). If you're on `http://example.com`, registration fails.
- **Wrong Content-Type:** The server isn't serving the SW with a JavaScript MIME type; check DevTools Network tab and verify `Content-Type: application/javascript; charset=utf-8`.
- **Bad scope:** The SW scope is broader than the directory it lives in, and the server isn't sending a `Service-Worker-Allowed` header. For `/public/sw.js`, the default scope is `/public/` and can't be broader without the header.
- **CORS or 404:** The URL is unreachable or returns a non-2xx status; check Network tab for the SW request.

**How to diagnose:**
1. Open DevTools → Application → Service Workers and look for an error message
2. Check Network tab for the `/sw.js` request:
   - Is the response 200? (If 404 or 5xx, the file isn't found)
   - Is the `Content-Type` header `application/javascript; charset=utf-8`?
   - Is `Cache-Control` set to `no-cache, no-store, must-revalidate`?
3. Check the console for the registration error message

**How to recover:**
- Confirm your app is on HTTPS (or localhost)
- Verify `/sw.js` is served with correct headers via next.config.ts (see Step 8)
- Keep the SW at the app root (`/public/sw.js`) so its default scope is broad enough
- Check that the scope parameter in the register call matches the intended scope: `navigator.serviceWorker.register('/sw.js', { scope: '/' })`

### Notification Permission Already Denied

**What it means:** `Notification.permission === 'denied'` and the user blocked notifications earlier.

**Can we ask again?** No. Once "denied", calling `requestPermission()` again resolves immediately with "denied"—no prompt appears. The user must manually unblock via browser settings.

**Best practices:**
- Never call `requestPermission()` repeatedly or on page load; always check permission state first
- Design your app to work well without notifications (use in-app badges or feed updates as fallback)
- When permission is "denied", show a helpful message explaining how to unblock (e.g., "Notifications are currently blocked. To enable them, click the lock icon next to the URL and select Allow Notifications.")

---

## Caching & Offline Support

The service worker above handles push notifications, but a production PWA should also cache assets and support offline browsing. Here's a simple cache-first strategy for static assets:

```javascript
// File: public/sw.js (additions for caching)

const CACHE_NAME = 'v1'
const urlsToCache = ['/offline', '/icon-192x192.png']

// Cache resources on install
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(urlsToCache)
    })
  )
})

// Serve from cache, fall back to network
self.addEventListener('fetch', (event) => {
  if (event.request.method !== 'GET') return

  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request).catch(() => {
        // Return offline page if both cache and network fail
        return caches.match('/offline')
      })
    })
  )
})
```

For more complex offline scenarios (sync, background tasks, multi-level caching), libraries like Serwist (next-pwa's successor) provide abstraction layers that reduce boilerplate. For most business apps, the simple cache-first strategy above combined with push notifications covers the core PWA requirements.

The caching strategy above handles browser-side asset caching. For server-side data caching — controlling revalidation and freshness in Next.js 16 — [Next.js 16.2 caching: unstable_cache vs use cache](/blog/nextjs-16-2-caching-unstable-cache-vs-use-cache) covers the model that governs your API and page data.

---

## Deploying to Production

When you deploy to production, ensure:

1. Your domain has a valid SSL certificate (most hosting providers handle this automatically)
2. Environment variables are set in your deployment platform (Vercel, your own server, etc.):
   - `NEXT_PUBLIC_VAPID_PUBLIC_KEY` (public, visible to browser)
   - `VAPID_PRIVATE_KEY` (secret, server-only)
3. Your manifest file is properly linked (Next.js does this automatically)
4. **Use a database instead of in-memory storage** for subscriptions. A simple schema:
   ```sql
   CREATE TABLE push_subscriptions (
     id SERIAL PRIMARY KEY,
     endpoint TEXT UNIQUE NOT NULL,
     p256dh TEXT NOT NULL,
     auth TEXT NOT NULL,
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
   )
   ```
5. **Monitor push delivery:** Track how many notifications are sent, how many fail, and why. Push services may throttle or revoke subscriptions if your notifications are marked as unwanted or if the origin is abusing the system.
6. **Track permission states:** Log denial rates and permission states to understand how many users block notifications and when. Chrome's quieter messaging and auto-revocation mean your permission grant rate may be lower than expected.

If you're deploying to AWS rather than Vercel, [running Next.js on AWS with OpenNext](/blog/opennext-aws-honest-2026-guide) covers the standalone build and deployment approach in a self-hosted environment.

## What You've Accomplished

You've built a production-ready PWA with end-to-end control. You own every piece: the manifest, service worker, caching, VAPID keys, and push subscription logic. No library abstractions hide the APIs, so when something breaks or needs tweaking, you know exactly where to look.

Your employees can install the app on their home screens and it launches in standalone mode—no browser chrome, just your app. Push notifications keep them engaged without requiring them to check actively. You deploy updates to your server and users get the latest version on their next launch, with no app store delays.

When errors occur—permission denial, subscription failures, service worker issues—you have the knowledge to diagnose them via the browser console, DevTools, and the troubleshooting patterns in this guide. You understand the trade-offs (manual control vs. library speed) and can make informed choices about when to add complexity (caching strategies, Serwist integration) later.

In our time tracking application, we saw significant improvements: employees were more engaged with shift notifications, response times to urgent delivery changes improved because notifications actually reached them, and support tickets related to "I missed an update" dropped dramatically.

This manual approach scales well for business applications because it gives you full visibility into behavior and performance, making it easier to debug issues and optimize for your specific use case.
---

## Aside: Quickly Generate Icons with ImageMagick on Mac

If you need to generate the icon files for your PWA, you can easily create them using ImageMagick via Homebrew. Here's the full command sequence to generate all three required icons from a source image:

```bash
# Install ImageMagick if you don't have it yet
brew install imagemagick

# Navigate to your project directory
cd /path/to/your/project/public

# Generate the standard icons
magick logo.png -resize 192x192 icon-192x192.png
magick logo.png -resize 512x512 icon-512x512.png

# Generate the maskable icon (centered with padding)
magick logo.png -resize 192x192 -background none -gravity center -extent 192x192 icon-192x192-maskable.png

# Generate the screenshot example
magick logo.png -resize 540x720 screenshot-1.png
```

The `brew install imagemagick` command installs ImageMagick, the most versatile CLI tool for image manipulation. The `-resize` flag scales your image to the specified dimensions. For maskable icons, the `-background none -gravity center -extent` flags ensure the image is centered and properly padded—this is important for devices that use adaptive icons on Android.

All commands preserve transparency and output to PNG by default. Replace `logo.png` with whatever your source image is named. If you're starting from scratch, you can also export your logo from Figma or another design tool at a high resolution (at least 512x512) and then run these commands.

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "Learn how to transform your Next.js 16 app into a powerful PWA with this detailed guide. Boost user engagement via push notifications!",
  "responses": [
    {
      "question": "What does the article \"Transform Your Next.js 16 App into a Powerful PWA\" cover?",
      "answer": "Learn how to transform your Next.js 16 app into a powerful PWA with this detailed guide. Boost user engagement via push notifications!"
    }
  ]
}
```