Web push notifications in React and Next.js
React apps need the same three things as any site — a service worker, a subscription and a sender. Next.js makes the server half easy with route handlers or server actions.
- Put sw.js in public/ so it’s served from your root.
- Subscribe in a client component, triggered by a button click.
- Send from a Next.js route handler or server action using the web-push package.
- Prefix only the public VAPID key with NEXT_PUBLIC_; keep the private key server-only.
1. Service worker in public/
Anything in Next.js’s public/ folder (or Vite/CRA’s public/) is served from the site root, so public/sw.js becomes /sw.js. Use the handlers from service worker push events. If your framework also generates a worker (for example a PWA plugin), merge push into it with importScripts rather than registering two.
2. A subscribe hook
"use client";
import { useState } from "react";
const KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;
const toBytes = (b64: string) => {
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const raw = atob((b64 + pad).replace(/-/g, "+").replace(/_/g, "/"));
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
};
export function NotifyButton() {
const [state, setState] = useState<"idle" | "on" | "denied">("idle");
async function enable() {
const reg = await navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" });
if ((await Notification.requestPermission()) !== "granted") return setState("denied");
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(KEY) });
await fetch("/api/push", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(sub) });
setState("on");
}
return <button onClick={enable} disabled={state === "on"}>{state === "on" ? "Alerts on" : "🔔 Get alerts"}</button>;
}3. A route handler that saves and sends
// app/api/push/route.ts
import webpush from "web-push";
webpush.setVapidDetails(
"mailto:push@yoursite.com",
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY! // server-only, never NEXT_PUBLIC_
);
export async function POST(req: Request) {
const sub = await req.json();
await db.subscriptions.upsert(sub); // your database
return new Response(null, { status: 201 });
}
export async function sendToAll(msg: { title: string; body: string; url: string }) {
for (const sub of await db.subscriptions.all()) {
try { await webpush.sendNotification(sub, JSON.stringify(msg), { TTL: 3600 }); }
catch (e: any) { if ([404, 410].includes(e.statusCode)) await db.subscriptions.remove(sub.endpoint); }
}
}Route handlers using web-push must run on the Node.js runtime, not the Edge runtime, because the library relies on Node crypto. More in Node.js web push.
4. iPhone notes
- Add a manifest with
"display": "standalone"(Next.js supportsapp/manifest.ts). - On iPhone, the button only works inside the Home Screen web app; detect standalone mode and show install steps otherwise (PWA guide).
- Check
"PushManager" in windowbefore rendering the button.
Serve sw.js with the right headers
Next.js serves files in public/ as static assets, which a CDN may cache. A stale worker means old push handling for days. The Next.js PWA guide recommends no-cache headers for the worker in next.config.js:
// next.config.js
module.exports = {
async headers() {
return [{ source: '/sw.js', headers: [
{ key: 'Content-Type', value: 'application/javascript; charset=utf-8' },
{ key: 'Cache-Control', value: 'no-cache, no-store, must-revalidate' },
] }];
},
};If you use Middleware (for auth or i18n), exclude /sw.js and your manifest from its matcher; a redirect on the worker URL makes registration fail with a confusing “bad HTTP response code” error.
Keys, env vars and Server Actions
Only the public VAPID key belongs in NEXT_PUBLIC_VAPID_PUBLIC_KEY; anything prefixed NEXT_PUBLIC_ ships to the browser. Keep the private key in a server-only variable and import the sending code only from Route Handlers or Server Actions (adding import 'server-only' to that module makes an accidental client import fail at build time). Server Actions are a neat fit for “subscribe” and “send test” buttons, but bulk sends belong in a background job or queue rather than a request that can time out on serverless platforms.
Debugging checklist
/sw.jsreturns 200 with a JavaScript content type, not your 404 page.- DevTools → Application → Service workers shows it activated with scope
/. Notification.permissionisgrantedandgetSubscription()returns an object.- Your server logs a 201 from the push service; a 403 usually means mismatched VAPID keys.
- OS notifications for the browser are enabled and Focus/Do Not Disturb is off.
Or use a script tag
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en"><body>
{children}
<Script src="https://pusharoo.com/sdk/pusharoo.js" data-site="pk_your_site_key" strategy="afterInteractive" />
</body></html>
);
}With Pusharoo’s sw.js in public/, that’s the whole integration: prompt, iPhone hint, subscriptions, campaigns and automations, free. Your own button can call window.Pusharoo.subscribe() (API).
Questions
Do I need next-pwa to send push notifications?
No. A plain service worker in public/ plus the Push API is enough. PWA plugins are optional, for offline caching and install prompts.
Can I send web push from the Next.js Edge runtime?
The web-push package needs Node.js APIs, so use the Node.js runtime for sending.
How do I test push in development?
Browsers treat http://localhost as secure, so service workers and push work locally. Use DevTools’ Push button to test the worker.
Does next-pwa conflict with my push worker?
next-pwa and similar plugins generate their own service worker. Either add your push listeners to that generated worker using the plugin’s custom-worker option, or skip the plugin and serve a hand-written public/sw.js.
Can I use Vercel Cron to send scheduled pushes?
Yes for small lists: a cron-triggered route can send a batch. For larger lists, have the cron job enqueue work for a worker so it doesn’t hit function time limits.
Keep reading
The Web Push API in JavaScript: a working example
Register a worker, ask on click, subscribe with VAPID, store the subscription and send — complete code.
Web push notifications with Node.js
Install web-push, generate keys, save subscriptions with Express, send with TTL and clean up dead endpoints.
Service worker push events, done right
push and notificationclick handlers, focusing open tabs, action buttons, subscription changes and pitfalls.
PWA push notifications on iOS and Android
Manifest, service worker, iPhone Home Screen requirement, Android install, badging and a setup checklist.
More in Developers: VAPID keys for web push, explained · The Web Push protocol, explained · Web push notifications in PHP · Laravel browser push notifications (no Firebase) · Declarative Web Push, explained · Firebase web push vs. standard web push · Web push in Python with pywebpush · Django push notifications to the browser · Web push notifications in Ruby on Rails · Web push in Go with webpush-go · Angular push notifications with SwPush · Push notifications in Vue 3 and Nuxt · Push notifications in SvelteKit · All guides
Free for life. No limits.
Unlimited subscribers, campaigns and automations. No badges. About five minutes to install.