better-push
Integrate your stackWalkthroughs

Next.js walkthrough

From nothing to a notification you can see, in five commands.

See it running and inspect apps/demos/next.

An existing Next.js app on Postgres, from nothing to a notification you can see. No VAPID keys to mint by hand, no service worker to write, no device to own, and no certificate to configure.

You need a Next.js project with a DATABASE_URL pointing at a Postgres you can write to. Everything else is below.

1. Scaffold

npx @better-push/cli init

It detects Next.js, your database client, your package manager, whether you use src/, and whether @/* maps to it - then shows the plan and waits.

Detected Next.js (App Router) · Drizzle ORM · package manager pnpm · source root src

Planned changes
create src/push.ts
create src/app/api/push/[...all]/route.ts
create src/db/better-push-schema.ts
create public/sw.js
env    .env.local (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, SESSION_SECRET, …)

Say yes.

2. Install

pnpm add @better-push/core pg

3. Create the tables

npx @better-push/cli migrate
  database  localhost:5432/app

  + bp_audit          + bp_delivery       + bp_device         + bp_digest_window
  + bp_job            + bp_metric_rollup  + bp_migration      + bp_notification
  + bp_preference     + bp_rollup_state   + bp_worker

  11 table(s) created, 24 index(es) created.

Want drizzle-kit to own these instead? init already wrote src/db/better-push-schema.ts. Re-export it from your schema and run drizzle-kit generate && drizzle-kit migrate - skip this step entirely.

4. Wire your session

This is the one thing no tool can do for you. Open src/push.ts and replace the TODO:

src/push.ts
session: async (request) => {
  const session = await auth(request);   // your auth, whatever it is
  return session ? { userId: session.user.id } : null;
},

Return { userId } for an authenticated request, null otherwise. The router answers 401 for null, and every endpoint is scoped to that user.

Using better-auth? One line:

import { betterAuthSession } from "@better-push/core/auth/better-auth";

session: betterAuthSession(auth),

5. Check it

npx @better-push/cli doctor
  ✓  Framework: Next.js (App Router)
  ✓  The mount file exists
  ✓  public/sw.js exists
  ✓  All 11 better-push tables exist
  ✓  VAPID keys are well-formed
  !  No queue is configured
  !  Nothing is ever deleted

  9 ok  3 warning(s)  0 errors

The warnings are folds you have not opened yet, not faults. Zero errors means it is wired up.

6. See a notification

Add the emulator provider - two lines, already commented in the file init wrote:

src/push.ts
import { emulator } from "@better-push/core/providers/emulator";

providers: [
  webPush({ vapid: { /* ... */ } }),
  ...(process.env.NODE_ENV === "production" ? [] : [emulator()]),
],

Start the inbox in a second terminal:

npx @better-push/cli dev

Register a virtual device from any client component:

src/app/dev-tools.tsx
"use client";
import { useEmulatorDevice } from "@better-push/core/react";

export function DevTools() {
  const emulator = useEmulatorDevice();
  if (process.env.NODE_ENV === "production") return null;
  return <button onClick={() => void emulator.register()}>Register device</button>;
}

Click it, then send from a server action or a route:

await push.notify({ userId: "your-user-id", title: "Hello", body: "It works." });

It appears at http://127.0.0.1:4984 immediately. No certificates, no permission prompt, no device.

7. Real push, and real UI

Now that the pipeline works, turn on the parts that need the browser.

npx @better-push/cli add notification-bell notification-preferences push-toggle
npx shadcn@latest add badge button popover scroll-area skeleton switch
src/app/layout.tsx
import { NotificationBell } from "@/components/better-push/notification-bell";

<header><NotificationBell /></header>
src/app/settings/notifications/page.tsx
import { PushToggle } from "@/components/better-push/push-toggle";
import { NotificationPreferences } from "@/components/better-push/notification-preferences";

export default function Page() {
  return (
    <>
      <PushToggle applicationServerKey={process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!} />
      <NotificationPreferences />
    </>
  );
}

Add NEXT_PUBLIC_VAPID_PUBLIC_KEY to .env.local with the same value init wrote as VAPID_PUBLIC_KEY - the browser needs it and NEXT_PUBLIC_ is how Next exposes it.

Click Enable push notifications, allow the prompt, and send again. This time it arrives as a real OS notification as well as in the feed.

Where to go next

On this page