Skip to content
Developers · 9 min read

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.

Updated By the Pusharoo team
In short
  • 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 supports app/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 window before 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

  1. /sw.js returns 200 with a JavaScript content type, not your 404 page.
  2. DevTools → Application → Service workers shows it activated with scope /.
  3. Notification.permission is granted and getSubscription() returns an object.
  4. Your server logs a 201 from the push service; a 403 usually means mismatched VAPID keys.
  5. 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.

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.