
Next.js Building a Progressive Web App (PWA)
Native mobile apps have three things a website usually doesn't: a home-screen icon, the ability to send push notifications, and an app-like full-screen presentation with no browser chrome. A Progressive Web App is the web's answer to all three, without the cost that comes with actually building a native app — no app store review process standing between you and shipping a fix, no separate codebase to maintain per platform, and instant updates the moment you deploy, rather than waiting on a review queue.
Next.js has built-in support for the piece that makes a PWA installable at all — the web app manifest — and the rest (push notifications, a service worker, install prompts) is standard web platform APIs wired into an App Router project. This article walks through building all of it, end to end.
Step 1: The web app manifest
The manifest is what tells a browser your site can behave like an app — its name, its icons, how it should be displayed once launched from a home screen. Next.js supports this natively as either a static app/manifest.json or, more flexibly, a dynamically generated app/manifest.ts:
// app/manifest.ts
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" },
],
};
}
display: 'standalone' is the field doing the actual "feels like a native app" work here — it tells the OS to launch your PWA without the browser's URL bar and navigation chrome, in its own window, indistinguishable at a glance from an installed native app. A favicon generator tool can produce the full icon set (multiple sizes, various formats) you'll need in public/ — this is genuinely worth using a generator for rather than hand-exporting every size yourself, since platforms are inconsistent about exactly which sizes they expect.
Step 2: Web Push Notifications
This is the feature that most changes the calculus of "do I need a native app for this." Push notifications work across essentially every modern browser now — iOS 16.4+ for home-screen-installed apps, Safari 16 on macOS 13+, every Chromium-based browser, and Firefox — which means the single biggest historical argument for going native (the ability to re-engage users when they're not actively in your app) is no longer native-exclusive.
The implementation has real moving pieces, so it's worth building up in order: a client component managing the subscription lifecycle, Server Actions handling the actual push-sending, VAPID keys authenticating your server as a legitimate sender, and a service worker that receives and displays the notification.
The subscription manager component
// app/page.tsx
"use client";
import { useState, useEffect } from "react";
import { subscribeUser, unsubscribeUser, sendNotification } from "./actions";
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;
}
function PushNotificationManager() {
const [isSupported, setIsSupported] = useState(false);
const [subscription, setSubscription] = useState<PushSubscription | null>(
null,
);
const [message, setMessage] = useState("");
useEffect(() => {
if ("serviceWorker" in navigator && "PushManager" in window) {
setIsSupported(true);
registerServiceWorker();
}
}, []);
async function registerServiceWorker() {
const registration = await navigator.serviceWorker.register(
new URL("../lib/service-worker.js", import.meta.url),
{ scope: "/", updateViaCache: "none" },
);
const sub = await registration.pushManager.getSubscription();
setSubscription(sub);
}
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)));
}
async function unsubscribeFromPush() {
await subscription?.unsubscribe();
setSubscription(null);
await unsubscribeUser();
}
async function sendTestNotification() {
if (subscription) {
await sendNotification(message);
setMessage("");
}
}
if (!isSupported) {
return <p>Push notifications are not supported in this browser.</p>;
}
return (
<div>
<h3>Push Notifications</h3>
{subscription ? (
<>
<p>You are subscribed to push notifications.</p>
<button onClick={unsubscribeFromPush}>Unsubscribe</button>
<input
type="text"
placeholder="Enter notification message"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button onClick={sendTestNotification}>Send Test</button>
</>
) : (
<>
<p>You are not subscribed to push notifications.</p>
<button onClick={subscribeToPush}>Subscribe</button>
</>
)}
</div>
);
}
userVisibleOnly: true isn't optional boilerplate — most browsers require it, and it's a real user-trust commitment: it tells the browser you promise every push you send will result in a visible notification, never a silent background wake-up the user can't see. Browsers enforce this to prevent exactly the kind of covert background tracking that would make push notifications a privacy liability rather than a feature.
VAPID keys
Web Push requires your server to authenticate itself to the push service using VAPID (Voluntary Application Server Identification) keys — a public/private keypair, generated once:
npm install -g web-push
web-push generate-vapid-keys
The output goes straight into your .env file:
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
Notice the naming discipline here, and it matters: the public key is prefixed NEXT_PUBLIC_ because the browser genuinely needs it (to construct the subscription), while the private key deliberately has no such prefix — it must never reach the client, since it's what lets your server authenticate outgoing pushes and would let anyone who obtained it impersonate your server entirely.
Server Actions for subscription management
// app/actions.ts
"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!,
);
let subscription: PushSubscription | null = null;
export async function subscribeUser(sub: PushSubscription) {
subscription = sub;
// In production, persist this to a database instead of a module-level variable
return { success: true };
}
export async function unsubscribeUser() {
subscription = null;
return { success: true };
}
export async function sendNotification(message: string) {
if (!subscription) {
throw new Error("No subscription available");
}
try {
await webpush.sendNotification(
subscription,
JSON.stringify({
title: "Test Notification",
body: message,
icon: "/icon.png",
}),
);
return { success: true };
} catch (error) {
console.error("Error sending push notification:", error);
return { success: false, error: "Failed to send notification" };
}
}
The in-memory subscription variable here is explicitly a placeholder for demonstration, not production code — a module-level variable in a serverless environment gets reset between invocations and doesn't survive a server restart, let alone scale to multiple users. Every real deployment of this needs subscriptions persisted to an actual database, keyed by user, so a restart or a new serverless instance doesn't silently drop everyone's subscriptions.
The service worker
This is the piece that actually receives a push event and turns it into a visible OS-level notification, running independently of whether your app's tab is even open:
// lib/service-worker.js
self.addEventListener("push", function (event) {
if (event.data) {
const data = event.data.json();
const options = {
body: data.body,
icon: data.icon || "/icon.png",
badge: "/badge.png",
vibrate: [100, 50, 100],
data: { dateOfArrival: Date.now(), primaryKey: "2" },
};
event.waitUntil(self.registration.showNotification(data.title, options));
}
});
self.addEventListener("notificationclick", function (event) {
event.notification.close();
event.waitUntil(clients.openWindow("https://your-website.com"));
});
Two things worth double-checking before shipping this: the vibrate pattern is a real, physical device behavior worth tuning deliberately rather than copy-pasting, and the hardcoded URL in notificationclick needs updating to your actual domain — leaving the placeholder in place means every notification click sends users to the wrong site, a mistake that's invisible until someone actually clicks a real notification in production.
Step 3: Making the app installable
The InstallPrompt component's job is specifically about the iOS gap: Safari on iOS doesn't support the beforeinstallprompt event other browsers use to trigger a native install banner, so iOS users need an explicit, manual instruction — tap Share, then "Add to Home Screen."
function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
useEffect(() => {
setIsIOS(/iPad|iPhone|iPod/.test(navigator.userAgent));
setIsStandalone(window.matchMedia("(display-mode: standalone)").matches);
}, []);
if (isStandalone) return null; // already installed — nothing to prompt
return (
<div>
<h3>Install App</h3>
<button>Add to Home Screen</button>
{isIOS && (
<p>
To install this app on your iOS device, tap the share button and then
"Add to Home Screen".
</p>
)}
</div>
);
}
Two requirements are non-negotiable for installability across browsers: a valid manifest (from Step 1) and the site actually served over HTTPS. Meet both, and modern browsers handle the install-prompt UI themselves for the non-iOS case — the docs specifically recommend against building a custom button around beforeinstallprompt, precisely because it's not cross-browser and doesn't work at all on Safari iOS, meaning a custom implementation built around it would silently exclude a meaningful chunk of your users while looking like it works fine in whatever browser you tested with.
Step 4: Testing this locally, correctly
Push notifications and service workers both have requirements that are easy to get wrong locally in ways that produce confusing "it's just not working" symptoms:
- HTTPS is required, even locally.
next dev --experimental-httpsgets you a locally-trusted certificate for exactly this purpose. - Browser notification permissions must actually be granted when prompted — check they're not disabled globally for the browser, separate from your site-specific permission.
- If one browser seems broken, try a different one before assuming your code is wrong. Chrome, Safari, and Firefox each have their own push implementation quirks, and cross-checking is often faster than debugging blind in just one.
Step 5: Securing the service worker specifically
Security headers matter for any production app, but a service worker has its own specific set of concerns beyond the generic ones, since it runs with elevated capabilities (intercepting requests, showing OS-level notifications) that ordinary page scripts don't have:
// next.config.js
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'",
},
],
},
];
},
};
The service-worker-specific Cache-Control: no-cache, no-store, must-revalidate deserves its own explanation, because it's counterintuitive at first glance — why aggressively prevent caching of a file? Because a stale, cached service worker is a genuinely worse failure mode than a slightly slower load: users can get stuck running an old version of your push-notification and offline logic indefinitely, since the browser's normal update-checking cycle for service workers is comparatively infrequent and cache headers can interfere with it further. Forcing revalidation on every load means users always pick up your latest service worker logic promptly, at the (small, one-file) cost of an extra network check.
Extending beyond the basics
Static exports change the architecture meaningfully. If your app needs to run with no server at all — a fully static export — Server Actions stop being available, and the push-subscription/notification-sending logic above needs to move to an external API you call directly instead, with the security headers defined at your proxy or CDN layer rather than in next.config.js.
Offline support has two tiers, worth not conflating. The experimental useOffline hook (covered in its own article in this series) gives you connectivity-aware UI and automatic retry of failed navigations and Server Actions — genuinely useful, but it is not full offline page loading. For an app that needs to actually load and function with zero network connectivity from a cold start, you need real service-worker-based asset caching, and Serwist is the maintained option with ready-made Next.js integration examples for both Turbopack and webpack.
There's a broader API surface worth knowing exists, even if this article doesn't cover it: background sync, periodic background sync, the File System Access API. Worth periodically checking a resource like "What PWA Can Do Today" for what's newly supported, since browser PWA capabilities have expanded steadily and what wasn't feasible a year or two ago may well be now.
Key Takeaways
| Piece | What it does | Where it lives |
|---|---|---|
| Web app manifest | Makes the app installable, defines icon/name/display mode | app/manifest.ts |
| VAPID keys | Authenticates your server to push services | .env (public key exposed, private key never) |
| Server Actions | Store subscriptions, send pushes | app/actions.ts |
| Service worker | Receives pushes, shows OS notifications | lib/service-worker.js |
| Install prompt | Native on most browsers; manual instructions needed for iOS | Client Component checking display-mode: standalone |
| Security headers | Prevent stale/compromised service worker, standard hardening | next.config.js headers() |
A PWA built this way genuinely closes most of the practical gap with a native app — installable, capable of push notifications, presentable without browser chrome — using nothing beyond standard web APIs and a small amount of Next.js-specific wiring. The parts worth taking seriously before shipping are the ones easy to skip in a demo: persisting subscriptions to a real database, getting the service worker's cache headers right, and testing across more than one browser, since each of those failure modes is invisible until a real user hits it in production.


