Building Real Push Notifications in a Full-Stack Next.js App Using Web Push

Table of contents
- Introduction
- Background Concepts
- Setup: VAPID Keys
- 1. Setting Up the Service Worker
- 2. Subscribing Users to Push Notifications
- 3. MongoDB Schema Update
- 4. Creating API Routes in Next.js
- 5. Sending a Push Notification
- Security & Reliability
- Extra: Unsubscribe Function
- Achievements
- Production Enhancements
- Summary

Advanced guide with full source code using
web-push
, MongoDB, and Next.js App Router.
Introduction
Push notifications bring real-time interaction to the web, even when your app is closed. Unlike polling or WebSockets, web push is battery-efficient and OS-level integrated.
In this post, I’ll walk you through implementing push notifications using web-push
, a Service Worker, MongoDB, and the App Router in Next.js—all without a separate backend. This is how I implemented it in my own production-grade Twitter clone.
Background Concepts
Push API vs Notifications API
Push API: Receives messages from the server (background). Learn more
Notifications API: Displays the message to the user. Learn more
Service Workers
Service workers are special JavaScript files that run in the background, separate from your web page, enabling powerful features like:
Push notifications
Background sync
Offline experiences (caching)
Intercepting network requests (like a proxy)
They are part of the Progressive Web App (PWA) stack. They receive push messages even when the browser is closed. Learn more.
VAPID Keys
Public/private key pair for identifying your server to push services. VAPID, which stands for Voluntary Application Server Identity, is a new way to send and receive website push notifications. Your VAPID keys allow you to send web push campaigns without having to send them through a service like Firebase Cloud Messaging (or FCM). Instead, the application server can voluntarily identify itself to your web push provider.
Setup: VAPID Keys
npx web-push generate-vapid-keys
Put the public key in your .env.local
:
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key
VAPID_PRIVATE_KEY=your_private_key
1. Setting Up the Service Worker
Create public/sw.js
:
self.addEventListener("push", (event) => {
const data = event.data.json();
self.registration.showNotification(data.title, {
body: data.body,
icon: "/favicon.ico",
data: { url: data.url },
});
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
if (event.notification.data?.url) {
event.waitUntil(clients.openWindow(event.notification.data.url));
}
});
Listens for incoming push notifications sent from your backend using the Web Push API.
Parses the push payload to extract title, body, icon, and a target URL.
Displays a native notification using
self.registration.showNotification()
.Handles notification clicks by opening the associated URL in a new tab or window.
Keeps the service worker alive using
event.waitUntil()
to complete asynchronous tasks likeopenWindow()
.
2. Subscribing Users to Push Notifications
hooks/usePushSubscription.ts
"use client";
import { useEffect } from "react";
export function usePushSubscription() {
useEffect(() => {
const registerPush = async () => {
if (
typeof window === "undefined" ||
!("serviceWorker" in navigator) ||
!("PushManager" in window)
)
return;
const alreadySubscribed = localStorage.getItem("isPushSubscribed");
if (alreadySubscribed === "true") return;
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const registration = await navigator.serviceWorker.register("/sw.js");
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
),
});
const res = await fetch("/api/notifications/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
});
if (res.ok) {
localStorage.setItem("isPushSubscribed", "true");
}
} catch (err) {
console.error("Push subscription failed", err);
}
};
registerPush();
}, []);
}
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, "+")
.replace(/_/g, "/");
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
export async function clearOldServiceWorkers() {
if ("serviceWorker" in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) {
await reg.unregister();
}
}
}
Registers a service worker (
/sw.js
) if not already done.Requests push notification permission from the user.
Subscribes to push notifications using the browser’s Push API and your VAPID public key.
Sends the subscription info to your backend (
/api/notifications/subscribe
) to store it for later pushes.Stores a local flag in
localStorage
to avoid resubscribing repeatedly.
components/PushWrapper.tsx
"use client";
import { usePushSubscription } from "hooks/usePushSubscription";
export default function PushWrapper({
children,
}: {
children: React.ReactNode;
}) {
usePushSubscription(); // 🔔 only called once for auth users
return (
<div>
{children}
</div>
);
}
Now, this PushWrapper is used to wrap the layout.tsx
to make sure the hook usePushSubscription
is called once per session per authenticated user.
app/(protected)/layout.tsx
import PushWrapper from "../../components/PushWrapper";
export default function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PushWrapper>
{children}
</PushWrapper>
);
}
3. MongoDB Schema Update
Extend your User
model to be able to save pushSubscription in database:
pushSubscription: {
endpoint: String,
keys: {
p256dh: String,
auth: String,
},
},
4. Creating API Routes in Next.js
app/api/notifications/subscribe/route.ts
import { validateToken } from "lib/auth";
import { connectToDatabase } from "lib/mongoose";
import { NextRequest, NextResponse } from "next/server";
import { User } from "utils/models/File";
export async function POST(req: NextRequest) {
try {
// add your database connection function here
await connectToDatabase();
// add your session validation logic here
const validationResponse = await validateToken(req);
if (validationResponse.status !== "ok") {
return NextResponse.json(
{ status: "error", message: validationResponse.message },
{ status: 401 }
);
}
const subscription = await req.json();
if (!validationResponse.user) throw new Error("User not found.");
await User.findByIdAndUpdate(validationResponse.user._id, {
pushSubscription: subscription,
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ status: "error", message: "Internal server error" },
{ status: 500 }
);
}
}
Connects to MongoDB using
connectToDatabase()
.Validates the user’s JWT with
validateToken(req)
to ensure authentication.Parses the incoming push subscription object from the request body.
Updates the authenticated user's
pushSubscription
field in the database.Returns a success or error response depending on the outcome.
5. Sending a Push Notification
lib/push.ts
import { ISerealizedUser } from "utils/types";
import webpush from "web-push";
let vapidInitialized = false;
export async function sendPush(
user: ISerealizedUser,
payload: { title: string; body: string; url: string }
) {
if (!vapidInitialized) {
webpush.setVapidDetails(
"mailto:your@email.com",
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
vapidInitialized = true;
}
if (!user.pushSubscription) return;
try {
await webpush.sendNotification(
user.pushSubscription,
JSON.stringify(payload)
);
} catch (err) {
console.error("Push failed", err);
throw err;
}
}
Initializes VAPID credentials (once) using
webpush.setVapidDetails()
for authentication with the push service.Skips sending if the user doesn't have a stored
pushSubscription
.Sends a push notification using the
web-push
library and the providedtitle
,body
, andurl
.Handles failures gracefully by logging and rethrowing any errors from
webpush.sendNotification
.
Trigger it after an action (e.g., new Like) from the server side:
app/api/like/[tweetId]
// rest of your route handler code
if (recipientDB.pushSubscription) {
try {
sendPush(recipientDB as unknown as ISerealizedUser, {
title: "New Like on Your Tweet",
body: `${userName} liked your tweet.`,
url: `/tweet/${tweet.postedTweetTime}`,
});
} catch (err) {
console.error("Web Push Error:", err);
}
}
// rest of your route handler code
Security & Reliability
Validate the session before storing subscriptions.
Catch and handle push errors (
410 Gone
= clean up).Always use HTTPS (Service Worker + Push APIs require it) unless on localhost.
// Optional: Remove invalid subscriptions
if (err.statusCode === 410) {
user.pushSubscription = undefined;
await user.save();
}
Extra: Unsubscribe Function
async function unsubscribePush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
localStorage.removeItem("isPushSubscribed");
await clearOldServiceWorkers();
}
}
Achievements
Push notifications without a separate backend
Fully integrated in App Router (Nextjs 15.2.2)
Per-user MongoDB subscriptions
Secure, scalable, and extensible foundation
Production Enhancements
Add a UI toggle to enable/disable push
export default function ToggleBtn = () => { const enablePushNotifications = async () => { await Notification.requestPermission(); }; return ( <> { Notification.permission !== "granted" && ( <button className="text-xs sm:text-sm cursor-pointer !p-1 text-gray-600 underline" onClick={enablePushNotifications} > Enable Push Notifications </button> } </> ) }
Track notification history in DB (Create a new model named Notification and store history there in case of MongoDB)
Auto-re-subscribe on
pushsubscriptionchange
self.addEventListener("pushsubscriptionchange", e => { // Re-subscribe and update DB });
Add
badge
support for mobile PWA
Summary
Push notifications are now table stakes for user engagement. With just a Service Worker, MongoDB, and Next.js App Router, you can bring native-like experiences to your web app.
For more such articles, follow the DevHub blog and do join our free of cost Tech community on Discord here. This feature is implemented in my side project which is a social media application resembling Twitter. Try it live or view code.
Subscribe to my newsletter
Read articles from Varun Kumawat directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Varun Kumawat
Varun Kumawat
Developer. Founder, DevHub. Mentor.