Coding Projects

Push Notifications From a Next.js Route Handler

Send a push notification from a Next.js route handler or server action, and keep the key server-side. The client-side version leaks it to everyone.

Red and black halftone illustration: a service window in a plain wall, its shutter half raised on darkness behind (TheNotificationApp)

Next.js blurs the line between client and server on purpose, and that is mostly a good thing. It is a bad thing for anything holding a credential, because the same-looking code runs in two very different places and only one of them is private.

Sending a push notification is one line. Sending it from the wrong side of that line publishes your app key to every visitor. Here is the version that does not.

Step 1: The environment variable

Create an application in TheNotificationApp, copy the app_key, and put it in .env.local:

TNA_APP_KEY=your_app_key_here

Do not prefix it with NEXT_PUBLIC_. That prefix is what tells Next.js to inline the value into the client bundle, and it is the single most common way keys end up in a JavaScript file anyone can read. If you have ever added it to make an error go away, that error was correct.

Step 2: A module that cannot be imported client-side

Install the server-only package. It does nothing at runtime; it makes the build fail if the module is imported into a client component, which turns a security mistake into a compile error:

npm install server-only
// lib/notify.ts
import "server-only";

const ENDPOINT = "https://thenotification.app/api/sendNotification";

type Notification = {
  title: string;
  body: string;
  link?: string;
  image?: string;
};

export async function notify(input: Notification): Promise<void> {
  const appKey = process.env.TNA_APP_KEY;
  if (!appKey) {
    console.warn("[notify] TNA_APP_KEY is not set, skipping");
    return;
  }

  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        app_key: appKey,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ ...input, body: input.body.slice(0, 180) }),
      cache: "no-store",
      signal: AbortSignal.timeout(5000),
    });

    if (!response.ok) {
      console.warn("[notify]", response.status, await response.text());
    }
  } catch (error) {
    console.warn("[notify] failed", error);
  }
}

Three details that matter in Next.js specifically. cache: "no-store" stops the framework from caching a POST it has no business caching. AbortSignal.timeout prevents a slow call from holding a serverless function open until it times out, which you pay for. And the function never throws, because a failed notification must not turn a successful order into a 500.

Step 3: Call it from a route handler

// app/api/webhooks/orders/route.ts
import { NextResponse } from "next/server";
import { notify } from "@/lib/notify";

export async function POST(request: Request) {
  const order = await request.json();

  await saveOrder(order);

  await notify({
    title: "New order",
    body: `${order.customerEmail} spent ${order.currency} ${order.total}`,
    link: `https://yourapp.example.com/admin/orders/${order.id}`,
  });

  return NextResponse.json({ received: true });
}

Or from a server action, which is the same idea with less ceremony:

"use server";

import { notify } from "@/lib/notify";

export async function submitContactForm(formData: FormData) {
  const email = String(formData.get("email"));
  const message = String(formData.get("message"));

  await saveMessage({ email, message });

  await notify({
    title: "New contact form message",
    body: `${email}: ${message}`,
  });
}

The mistake worth naming

Someone will suggest calling the API directly from a client component, because it is one fetch and it works when you try it.

"use client";

// Do not do this. The key ships to the browser.
await fetch("https://thenotification.app/api/sendNotification", {
  method: "POST",
  headers: { app_key: process.env.NEXT_PUBLIC_TNA_APP_KEY! },
  body: JSON.stringify({ title: "New signup", body: email }),
});

That key is now in your JavaScript bundle, readable by anyone who opens devtools, and it will stay in every cached copy of that bundle after you rotate it. Anyone who finds it can send notifications to your phone at whatever rate they like until you notice.

The server-only import from step 2 exists so this fails at build time rather than in production.

Not blocking the response

Awaiting the notification adds its latency to the response. For a webhook that nobody is waiting on, that is fine. For a form submission where a person is watching a spinner, it is not.

On Vercel, waitUntil lets the response go out while the notification finishes:

import { waitUntil } from "@vercel/functions";

export async function POST(request: Request) {
  const order = await request.json();
  await saveOrder(order);

  waitUntil(
    notify({ title: "New order", body: `${order.customerEmail}` })
  );

  return NextResponse.json({ received: true });
}

Be careful with the general version of this. Firing the promise without awaiting it and without waitUntil works on a long-lived Node server and silently drops the request on a serverless platform, because the function is frozen the moment the response is returned. If you are not sure which one you are on, await it.

What is worth notifying about

  • A new order or payment. The obvious one, and the one people actually want.
  • A contact form submission. Reply speed matters more than almost anything else for a small product.
  • A webhook that failed to process. These fail silently by design and you find out days later.
  • A cron route that errored. Scheduled routes are invisible until they are not.

Not worth it: page views, sign-ins, anything that happens more than a handful of times a day.

The honest part

The free tier is 100 notifications for the lifetime of the account, not per month. For a side project where an order is an event, that is a good fit and a nice ceiling to hit. For a site with steady traffic it is a day, so put a condition in front of anything frequent.

Pro is $2.99 a month for 1,000. And this reaches iPhone only, since it rides Apple's push service.

Where this fits

The same call from plain Node is in sending push notifications from Node.js, and if you would rather not write a route handler at all, a webhook URL does the same job with no code: webhook push notifications. The field list is in the API reference.

Grab a free key at thenotification.app and find out about the order before the customer emails you about it.

New to this? Start with what a push notification API actually is.