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

Varun KumawatVarun Kumawat
7 min read

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
💡
Do not expose the Vapid Private key on the Client side.

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 like openWindow().

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 provided title, body, and url.

  • 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.

10
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.