Blog · Guides

Push notifications in a Next.js app, step by step

A service worker in public, a client component that subscribes from a click and a server action that sends with the secret key: web push in a Next.js App Router app.

Leer en español

Adding push notifications to a Next.js App Router app takes four pieces: a service worker in public/, a client component that asks for permission from a click, somewhere to store subscriptions, and server code that sends with a secret key. With castly the last two become HTTP calls; here is every file, step by step.

In short:

  • public/castly-sw.js holds a single line, and Next.js serves it at /castly-sw.js.
  • Load the SDK in a client component with import(/* webpackIgnore: true */ …), which works with both Turbopack and webpack.
  • Call Castly.subscribe({ userId }) directly in onClick, with no await before it, so the browser shows its permission prompt.
  • Send from a server action or route handler, with the secret key in an env var that has no NEXT_PUBLIC_ prefix.
  • On iPhone, web push only works once the site is added to the Home Screen; Castly.needsInstall() tells you when that is the case.

What you will build

FileRuns inPurpose
public/castly-sw.jsService workerReceives the push and shows the notification
types/castly.d.tsTypeScript onlyTypes for the remote SDK module
app/push-button.tsxBrowserButton that subscribes the user
lib/push.tsServerCalls the send API
app/admin/orders/actions.tsServerServer action that triggers the notification

Plus two variables in .env.local:

NEXT_PUBLIC_CASTLY_PUBLIC_KEY=pk_live_…
CASTLY_SECRET_KEY=sk_live_…

The prefix difference matters. Next.js only inlines variables starting with NEXT_PUBLIC_ into the browser bundle; everything else becomes an empty string on the client. The public key is meant for browsers (it can only register subscribers), while the secret key can message every user you have and must never carry that prefix.

Step 1: the service worker in public

Anything in public/ is served from the root of your domain. Create public/castly-sw.js with this line:

importScripts('https://castly.sh/sdk/v1/sw.js');

Because it lives at the root, its scope covers every route in the app. If you already ship your own service worker (say, your app is a PWA with offline caching), don't register a second one on the same scope: add that line to yours and pass its path in Castly.init({ …, swPath: '/sw.js' }).

In development, http://localhost counts as a secure context, so service workers work under next dev. Another device on your network hitting http://192.168… does not, because it isn't HTTPS.

Step 2: load the SDK in a client component

The SDK isn't installed from npm; it loads from its URL as an ES module. Turbopack, the default Next.js bundler, doesn't support URL imports (experimental.urlImports is not planned for it), but both bundlers honor the webpackIgnore: true comment on a dynamic import(): they leave it untouched and the browser downloads the module at runtime.

To make TypeScript accept that URL, declare the module in types/castly.d.ts with the methods you use:

declare module 'https://castly.sh/sdk/v1/castly.mjs' {
  export interface CastlyClient {
    init(options: { publicKey: string; autoPrompt?: boolean; swPath?: string }): unknown;
    isSupported(): boolean;
    needsInstall(): boolean;
    permission(): NotificationPermission | 'unsupported';
    subscribe(options?: { userId?: string; tags?: Record<string, string> }): Promise<{ id: string }>;
    unsubscribe(): Promise<void>;
  }
  export const Castly: CastlyClient;
}

If you'd rather not deal with imports at all, load https://castly.sh/sdk/v1/castly.js with next/script and use the global window.Castly. The rest of the flow is identical.

Step 3: ask for permission from a click

Browsers only show the permission prompt when the request comes from a user gesture, and Safari and Firefox are strict about it (see MDN on requestPermission). So the SDK loads and initializes when the component mounts, and the click handler only calls subscribe, awaiting nothing first:

'use client';

import { useEffect, useRef, useState } from 'react';
import type { CastlyClient } from 'https://castly.sh/sdk/v1/castly.mjs';

type State = 'loading' | 'unsupported' | 'needs-install' | 'blocked' | 'ready' | 'on';

export function PushButton({ userId }: { userId: string }) {
  const castly = useRef<CastlyClient | null>(null);
  const [state, setState] = useState<State>('loading');

  useEffect(() => {
    import(/* webpackIgnore: true */ 'https://castly.sh/sdk/v1/castly.mjs')
      .then(({ Castly }) => {
        Castly.init({ publicKey: process.env.NEXT_PUBLIC_CASTLY_PUBLIC_KEY!, autoPrompt: false });
        castly.current = Castly;
        if (!Castly.isSupported()) setState('unsupported');
        else if (Castly.needsInstall()) setState('needs-install');
        else if (Castly.permission() === 'denied') setState('blocked');
        else setState('ready');
      })
      .catch(() => setState('unsupported'));
  }, []);

  async function enable() {
    const c = castly.current;
    if (!c) return;
    try {
      await c.subscribe({ userId }); // first call in the click: the gesture is still valid
      setState('on');
    } catch {
      setState(c.permission() === 'denied' ? 'blocked' : 'ready');
    }
  }

  if (state === 'unsupported') return null;
  if (state === 'needs-install') return <p>On iPhone, add this site to your Home Screen and open it from there.</p>;
  if (state === 'blocked') return <p>Notifications are blocked; you can re-enable them in site settings.</p>;
  if (state === 'on') return <p>Notifications are on for this device.</p>;
  return (
    <button onClick={enable} disabled={state === 'loading'}>
      Notify me about my order
    </button>
  );
}

Render it from any server page, passing the ID from your session: <PushButton userId={session.user.id} />. That userId links the device to your user, so you can later send to them by ID without storing endpoints in your own database. If you also pass tags when subscribing, such as tags: { plan: 'pro', lang: 'en' }, you can target a segment with { "type": "segment", "tags": { "plan": "pro" } } without querying anything on your side. One person can subscribe both a laptop and a phone: both devices are linked to the same userId and both get the notification.

autoPrompt: false turns off the opt-in prompt configured in the dashboard, since here the button is yours. If you prefer that prompt (copy, position, delay and pages are configured without code), just call init without that option; the opt-in prompt docs list everything you can configure.

Step 4: send from the server

Keep the API call in a server module. import 'server-only' makes the build fail if anyone imports it from a client component:

// lib/push.ts
import 'server-only';

type Target =
  | { type: 'all' }
  | { type: 'users'; userIds: string[] }
  | { type: 'segment'; tags: Record<string, string> };

export async function sendPush(
  message: { to: Target; title: string; body?: string; url?: string; ttl?: number },
  idempotencyKey: string,
) {
  const res = await fetch('https://castly.sh/v1/notifications', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CASTLY_SECRET_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify(message),
  });
  if (res.status !== 202) throw new Error(`Send rejected (${res.status}): ${await res.text()}`);
  return (await res.json()) as { id: string; status: string };
}

Then call it where the event happens, for example in a server action in your orders admin:

// app/admin/orders/actions.ts
'use server';

import { sendPush } from '@/lib/push';
import { requireAdmin, markShipped } from '@/lib/orders'; // your own code

export async function shipOrder(orderId: string) {
  await requireAdmin(); // a server action is a public endpoint: always check
  const order = await markShipped(orderId);
  await sendPush(
    {
      to: { type: 'users', userIds: [order.userId] },
      title: 'Your order is on its way',
      body: `Order ${order.number} leaves the warehouse today.`,
      url: `https://yourshop.com/orders/${order.id}`,
    },
    `order:${order.id}:shipped`,
  );
}

Decide whether the push is essential to the action. The API answers 202 as soon as the notification is validated and queued, so the call is quick, but a network hiccup would still make shipOrder throw after the order is already marked as shipped. If the notification is secondary, wrap the call in try/catch, log the error and let the action succeed; you can resend later with the same key without risking a duplicate.

If the trigger lives outside Next.js (a cron job, your payment provider's webhook), use a route handler at app/api/push/route.ts that exports POST, checks a token of your own and calls the same function. The idempotency key prevents duplicate notifications if the action is retried; the reasoning is in Idempotency-Key for push notifications.

Test and debug

  • Test device: in the Playground of the castly dashboard you can subscribe your own browser, which is flagged as a test device. Sending with "to": { "type": "test" } lets you try copy without bothering anyone.
  • Service worker: in Chrome DevTools, the Application panel shows whether /castly-sw.js is registered and active, and with which scope.
  • Logs: keep the id that sendPush returns. GET /v1/notifications/:id shows the status on every device (sent, delivered, clicked), so you know whether a given notification actually arrived.
  • Content Security Policy: if your app sets a CSP in next.config or middleware, add https://castly.sh to script-src and connect-src.
  • iPhone and iPad: you need HTTPS, a manifest (in App Router, app/manifest.ts) and the user opening the site from the Home Screen. The same rules apply to any framework, as the Vue guide also covers.

Frequently asked questions

Can I call Castly.subscribe on page load?

Better not: without a user gesture, several browsers block or ignore the permission request, and a prompt with no context tends to end in "Block". Call it from a click, once the user understands what they will receive.

Does it work with Turbopack?

Yes. Turbopack doesn't support URL imports, but it honors the webpackIgnore: true comment on a dynamic import(), just like webpack, so the browser loads the module directly.

Where do I keep the secret key in Next.js?

In an environment variable without the NEXT_PUBLIC_ prefix, such as CASTLY_SECRET_KEY, read only from server code: route handlers, server actions or modules marked with server-only.

Do I need to store subscriptions in my own database?

No. If you pass userId when subscribing, you can send to { "type": "users", "userIds": [...] } using your own user IDs, and the service keeps each device's endpoint.

What if I already have a service worker for my PWA?

Add importScripts('https://castly.sh/sdk/v1/sw.js') to your service worker and pass its path as swPath when initializing. That way two registrations never compete for the same scope.

Put it into practice with castlyWeb and PWA push notifications for developers: an SDK under 3 KB, a REST API and delivery logs for every device. Free up to 10,000 pushes a month.
Create a free account →

Send your first push notification today.

Create a project, add the SDK and send with one API call. Free to start.

Create a free account →