Capítulo 72 de 456

PWAs

Core Idea

Build a Progressive Web App with Next.js: a web app manifest for installability, Web Push notifications via a service worker + Server Actions + VAPID keys, and security headers, without needing a separate native codebase.

Key Concepts

  • app/manifest.ts: built-in file convention that generates the web app manifest (name, icons, display: 'standalone', colors) enabling home-screen installation.
  • Web Push Notifications: supported on iOS 16.4+ (installed to home screen), Safari 16+ (macOS 13+), Chromium browsers, and Firefox; viable native-app alternative without requiring offline support.
  • Service Worker: lib/service-worker.js, registered via navigator.serviceWorker.register, listens for push and notificationclick events to show/handle notifications.
  • VAPID keys: generated via web-push generate-vapid-keys CLI; public key goes in NEXT_PUBLIC_VAPID_PUBLIC_KEY, private key in VAPID_PRIVATE_KEY.
  • Server Actions for push: subscribeUser, unsubscribeUser, sendNotification in app/actions.ts ('use server'), using the web-push package server-side.
  • useOffline (experimental): hook + matching experimental.useOffline config for connectivity-aware UI and automatic retry of failed navigations/Server Actions; not full offline caching.
  • Serwist: recommended third-party option for full service-worker-based offline caching (Turbopack and webpack examples provided).

Code Examples

import type { MetadataRoute } from 'next'

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'Next.js PWA',
    short_name: 'NextPWA',
    description: 'A Progressive Web App built with Next.js',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#000000',
    icons: [
      { src: '/icon-192x192.png', sizes: '192x192', type: 'image/png' },
      { src: '/icon-512x512.png', sizes: '512x512', type: 'image/png' },
    ],
  }
}
  • O que demonstra: manifest mínimo necessário para instalação em tela inicial.
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready
  const sub = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
  })
  setSubscription(sub)
  await subscribeUser(JSON.parse(JSON.stringify(sub)))
}
  • O que demonstra: cliente se inscreve no push manager e persiste a subscription via Server Action.
'use server'
import webpush from 'web-push'

webpush.setVapidDetails(
  '<mailto:your-email@example.com>',
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
)

export async function sendNotification(message: string) {
  if (!subscription) throw new Error('No subscription available')
  await webpush.sendNotification(subscription, JSON.stringify({ title: 'Test Notification', body: message, icon: '/icon.png' }))
  return { success: true }
}
  • O que demonstra: Server Action que envia a notificação push usando web-push; em produção a subscription deve ir para um banco, não variável em memória.
self.addEventListener('push', function (event) {
  if (event.data) {
    const data = event.data.json()
    event.waitUntil(self.registration.showNotification(data.title, { body: data.body, icon: data.icon || '/icon.png' }))
  }
})
self.addEventListener('notificationclick', function (event) {
  event.notification.close()
  event.waitUntil(clients.openWindow('<https://your-website.com>'))
})
  • O que demonstra: service worker mínimo que exibe a notificação recebida e trata o clique nela.
module.exports = {
  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'" },
      ]},
    ]
  },
}
  • O que demonstra: headers de segurança globais e específicos para o service worker (nunca cachear o sw.js).

Reference Tables

Requisito de instalaçãoDetalhe
Web app manifest válidoCriado via app/manifest.ts
Servido via HTTPSObrigatório; teste local com next dev --experimental-https

Anti-patterns

  • Usar beforeinstallprompt para botão customizado de instalação: não é cross-browser/plataforma (não funciona no Safari iOS); a doc recomenda não usar.
  • Guardar subscription só em variável em memória do servidor: não sobrevive a restart nem escala para múltiplos usuários; use banco de dados em produção.
  • Cachear sw.js: força usuários a ficar com versão antiga do service worker; sempre no-cache, no-store, must-revalidate.
  • Migrar para static export sem trocar Server Actions por API externa: static export exige mover a lógica de push/headers para fora do Next.js.

Key Takeaways

  1. Um PWA instalável precisa só de manifest válido + HTTPS; navegadores modernos mostram o prompt de instalação sozinhos.
  2. Web Push funciona sem exigir suporte offline completo, tornando PWA viável como alternativa a apps nativos.
  3. VAPID keys são geradas uma vez via CLI e usadas tanto no cliente (NEXT_PUBLIC_VAPID_PUBLIC_KEY) quanto no servidor (VAPID_PRIVATE_KEY).
  4. Para static export, é preciso trocar Server Actions por chamadas a API externa e mover os headers para o proxy.
  5. useOffline (experimental) cobre UI ciente de conectividade e retry automático; para cache offline completo via service worker, usar Serwist.

Connects To

  • manifest.json (api-reference): referência completa de campos do manifest.
  • content-security-policy: aprofunda os headers de CSP citados aqui.
  • static-exports: caminho alternativo sem servidor, citado na seção "Extending your PWA".