Skip to main content
PWANext.jsWeb Push

Building PWA Features in Next.js: Service Workers and Web Push Notifications

elango
elango

Full Stack Engineer

Updated
5 min read
Building PWA Features in Next.js: Service Workers and Web Push Notifications

Why I added push to chat instead of email alone

My portfolio includes a direct chat feature so recruiters and collaborators can reach me without leaving elangodev.com. Email notifications worked, but they felt slow for a conversation. When I reply from my phone between meetings, I want the other person to know immediately — especially if they granted notification permission while browsing on desktop.

I chose web push over building a native app because my audience already lands on the site from LinkedIn and search. A Progressive Web App layer — service worker, manifest, push — extends the browser session without App Store friction. The implementation lives mostly in app/api/chat/send/route.js and a push_subscriptions table in Supabase.

VAPID keys and server-side setup

Web push requires VAPID keys: a public key the browser uses when subscribing, and a private key the server uses to sign payloads. I store them as NEXT_PUBLIC_VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY in Vercel environment variables. On each POST to /api/chat/send, I initialize the web-push library before handling the message body.

I set the contact email in setVapidDetails because push services expect a mailto identifier for the application server. This is easy to forget when copying tutorials that hard-code example domains.

if (
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY &&
  process.env.VAPID_PRIVATE_KEY
) {
  webpush.setVapidDetails(
    "mailto:b.elango93@gmail.com",
    process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
    process.env.VAPID_PRIVATE_KEY
  );
}

Securing the send route before any notification fires

Push is useless if anyone can impersonate a sender. My route validates the Supabase session — Bearer token or cookie-based getUser — then compares user.id to sender_id in the JSON body. I return 403 with a blunt message if they mismatch. Only after that gate do I insert into chat_messages with the admin client.

Admin replies have an extra check: is_admin_reply must match my ADMIN_EMAIL. Regular users cannot mark messages as official support responses. That separation keeps notification copy trustworthy when someone sees "New message from Elango" on their lock screen.

const { data: subscriptions } = await supabaseAdmin
  .from("push_subscriptions")
  .select("*")
  .eq("user_id", receiver_id);

const payload = JSON.stringify({
  title: `New message from ${senderName}`,
  body: message || "Sent an attachment",
  url: is_admin_reply ? "/chat" : `/chat?chat=${sender_id}`,
});

await Promise.allSettled(
  subscriptions.map((sub) =>
    webpush.sendNotification(
      { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
      payload
    )
  )
);

Subscription storage and cleanup

When a visitor opts in on the client, I persist endpoint, p256dh, and auth keys in push_subscriptions keyed by user_id. One user may have multiple rows — phone, laptop, stale tabs — so I map over all subscriptions when sending. Failed deliveries with HTTP 410 or 404 mean the browser revoked the subscription; I delete those rows so I do not retry forever.

I wrapped push dispatch in try/catch separate from the message insert. A broken push provider must never roll back a successfully stored chat message. Users still see history when they open /chat even if notifications fail silently.

Service worker responsibilities on elangodev.com

Push display happens in the service worker, not in React. The worker receives the encrypted payload, calls showNotification with title and body from JSON, and sets data.url so a click opens the right thread. I keep the worker focused: cache shell assets for repeat visits, handle push events, skip aggressive offline caching of API responses that would show stale portfolio data.

My manifest links icons and theme colors consistent with the main site so installed PWAs feel like elangodev.com, not a generic wrapper. blog and handbook pages remain online-first; I did not try to offline the entire lab because Monaco and canvas utilities exceed reasonable cache budgets.

CORS and deployment on Vercel

The chat send route exports explicit CORS headers because early mobile tests hit the API from embedded WebViews during debugging. OPTIONS returns 204 with allowed methods. In production, same-origin fetches from elangodev.com are the primary path, but explicit headers saved hours when I tested from local HTTPS tunnels.

Serverless functions cold-start in a few hundred milliseconds — acceptable for chat sends that already round-trip to Supabase. I do not maintain a WebSocket server; push closes the loop asynchronously when the recipient is away.

Privacy and consent alignment

Push permission is separate from my cookie banner. Analytics and AdSense wait for hasAnalyticsConsent() from lib/consent.js, but notifications require an explicit browser prompt. I only subscribe after login so subscriptions tie to real user rows, not anonymous fingerprints.

When someone chooses Essential Only cookies, chat still works; they simply will not get marketing analytics. Push remains optional and revocable through browser settings — I delete stale subscriptions server-side when push services report them dead.

Lessons from shipping push on a personal site

Start with one event type — new chat message — before generalizing to blog comments or freelance requests. Validate auth on the API route before touching webpush. Store subscriptions in Postgres alongside users so RLS policies can apply on read paths. Test on Chrome desktop and Android; Safari push has improved but still deserves its own checklist.

Building PWA features on Next.js does not require abandoning your existing Supabase architecture. I kept messages in chat_messages, added push_subscriptions, and extended one route handler. That incremental approach let me ship notifications to production without a dedicated Node push daemon — exactly the kind of leverage I want from serverless on elangodev.com.

elango

Written by

elango

Full Stack Engineer & Software Architect specializing in React, Next.js, and high-performance web applications.

Share on:

Discover More

View blog