better-push
Integrate your stackWalkthroughs

NestJS walkthrough

better-push on NestJS, including the basePath trap.

See it running and inspect apps/demos/nestjs.

A NestJS API, from nothing to a notification you can see. Nest is the stack with one trap worth naming up front, so it gets said twice.

You need a Nest project with a DATABASE_URL.

1. Scaffold

npx @better-push/cli init --framework nestjs
Detected NestJS · Prisma · package manager pnpm · source root src

Planned changes
create src/push.ts
create src/push.module.ts
create prisma/better-push.prisma
create prisma/better-push-indexes.sql
create prisma.config.ts
env    .env.local (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, …)

init detects Nest even though @nestjs/platform-express depends on Express - the more specific answer is the right one.

2. Install and import

pnpm add @better-push/core @prisma/client @prisma/adapter-pg pg
src/app.module.ts
import { Module } from "@nestjs/common";
import { PushModule } from "./push.module";

@Module({ imports: [PushModule] })
export class AppModule {}

That is the whole mount. The module routes through configure() middleware, so there is no controller to write and no route decorator to keep in sync.

3. The basePath trap

Two places must agree, and both fail silently

forRootAsync requires an explicit basePath. The instance the factory returns does not exist when Nest configures routing, so its own basePath cannot be read. init writes "/api/push" in both files.

The module is middleware, not a controller, so app.setGlobalPrefix() is not applied to it. If you set a global prefix, include it in basePath here and in push.ts.

Neither mistake fails to compile. Both 404 every push endpoint at runtime, which is a confusing afternoon.

src/push.module.ts
@Module({
  imports: [
    BetterPushModule.forRootAsync({
      basePath: "/api/push",   // must match push.ts, prefix included
      useFactory: () => push,
    }),
  ],
})
export class PushModule {}

With app.setGlobalPrefix("v1"), both become "/v1/api/push".

4. Create the tables

npx @better-push/cli migrate

Prisma users: run the index SQL, or run migrate

prisma migrate builds a database missing all three of better-push's partial indexes, because Prisma cannot express them and silently ignores what it cannot express. One of them, bp_digest_window_open_unique, carries behaviour: without it a burst of twenty events flushes as twenty notifications instead of one digest.

better-push migrate creates everything. If you would rather Prisma owned the tables, run prisma migrate dev and then psql "$DATABASE_URL" -f prisma/better-push-indexes.sql - every time you reset the database.

5. Wire your session

src/push.ts
session: async (request) => {
  const token = request.headers.get("authorization")?.replace("Bearer ", "");
  const user = token ? await verifyJwt(token) : null;
  return user ? { userId: user.sub } : null;
},

The resolver takes a web-standard Request, not Nest's req - the bridge converts one to the other, so nothing here depends on Nest's platform.

Need Nest DI inside the resolver? forRootAsync takes imports and inject like any Nest async provider, so the factory can build the instance from injected services.

6. Check it

npx @better-push/cli doctor

doctor finds src/push.module.ts, confirms the schema, and - crucially for a Prisma project - reports the three partial indexes as missing if you took the prisma migrate path and skipped the SQL.

7. See a notification

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

providers: [
  webPush({ vapid: { /* ... */ } }),
  ...(process.env.NODE_ENV === "production" ? [] : [emulator()]),
],
npx @better-push/cli dev
curl -X POST http://localhost:3000/api/push/devices \
  -H "authorization: Bearer $YOUR_TOKEN" \
  -H "content-type: application/json" \
  -d '{"platform":"android","provider":"emulator","token":"emulator-dev-1"}'
await push.notify({ userId: "alice", title: "Hello", body: "It works." });

It appears at http://127.0.0.1:4984. If nothing arrives, the basePath is the first thing to check - see step 3.

More than one instance

Each forRoot/forRootAsync registration owns its own module class and mount, so registering two instances at two paths works and neither overwrites the other's routing.

Where to go next

On this page