Installation

Two paths to get started — pick based on where you are:

New project

Use the starter scaffold. A full Next.js app with notifications wired end-to-end. Best for exploring or starting fresh.

Existing app

Install the packages and add three files. Best when you're adding notifications to something already running.

Starter scaffold

npx create-notifykit-app my-app
cd my-app
cp .env.example .env.local
# generate a 32-byte hex secret:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# paste as NOTIFYKIT_SECRET in .env.local

npm install
npm run dev

Open http://localhost:3000. Sign in as the demo user, send yourself a test notification, manage preferences at /settings/notifications. The scaffold uses the in-memory adapter and a fake email provider so it works offline.

Scaffold file map

Here's what the generated project looks like and where to find each piece:

FileWhat it doesEdit when
lib/notifykit.tsNotifyKit instance + notification definitionsAdding notifications, swapping database/provider
app/_components/demo-sender.tsxDemo form that sends through the local handler runtimeReplacing the demo flow with your app's real send trigger
app/api/notifykit/[...route]/route.tsREST handler — inbox, preferences, SSE, unsubscribeChanging auth, adding CORS, protecting routes
app/layout.tsx<NotifyKitProvider> wrapperChanging baseUrl or conditional rendering
app/settings/notifications/page.tsxPreferences UI with per-channel togglesCustomizing the settings layout or adding categories
app/_components/inbox-view.tsxBell count + inbox listStyling the inbox or customizing item rendering
.env.exampleRequired env vars with commentsAdding provider API keys for production
Start in lib/notifykit.ts. That's the one file that controls everything — your notification definitions, database adapter, and provider config. Edit it, save, and the dev server hot-reloads. The rest of the scaffold just wires things up.

Or install into an existing Next.js app

npm install @notifykitjs/core @notifykitjs/next @notifykitjs/react

A minimal setup needs three files:

lib/notifykit.ts
import {
  channel,
  createNotifyKit,
  fakeEmailProvider,
  memoryAdapter,
  notification,
} from "@notifykitjs/core"

const inbox = channel.inbox()
const email = channel.email()

export const commentMentioned = notification({
  id: "comment_mentioned",
  payload: { actorName: "string", postUrl: "string" },
  channels: [
    inbox({ title: "{{actorName}} mentioned you" }),
    email({
      subject: "{{actorName}} mentioned you",
      body: "Open {{postUrl}} to reply.\n\n---\nUnsubscribe: {{_unsubscribeUrl}}",
    }),
  ],
})

export const notify = createNotifyKit({
  notifications: [commentMentioned] as const,
  database: memoryAdapter(),
  providers: { email: fakeEmailProvider() },
  unsubscribe: {
    secret: process.env.NOTIFYKIT_SECRET!,
    baseUrl: "http://localhost:3000/api/notifykit",
  },
})
app/api/notifykit/[...route]/route.ts
import { createRouteHandler } from "@notifykitjs/next"
import { notify } from "@/lib/notifykit"
import { getCurrentUserId } from "@/lib/session"

export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler({
  notifykit: notify,
  identify: () => getCurrentUserId(),
  unsubscribeSecret: process.env.NOTIFYKIT_SECRET,
})
app/layout.tsx
import { NotifyKitProvider } from "@notifykitjs/react"

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <NotifyKitProvider options={{ baseUrl: "/api/notifykit" }}>
          {children}
        </NotifyKitProvider>
      </body>
    </html>
  )
}

Verify it works

After adding those three files, start the dev server and hit the health endpoint:

npm run dev
curl http://localhost:3000/api/notifykit/notifications

You should see a JSON response containing your registered notifications:

{ "data": [{ "id": "comment_mentioned", "channels": ["inbox", "email"], ... }] }
What you seeWhat it meansIf it fails
JSON with a data array containing your notification IDsHandler is wired up and NotifyKit instance is loading
404Route not foundCheck the file is at app/api/notifykit/[...route]/route.ts
500 / module errorImport or config issueCheck the terminal — usually a missing env var or bad import path

Send your first notification

The handler responds — now verify the full pipeline. Run this one-off script to create a recipient, send a notification, and confirm it landed in the inbox:

// scripts/verify-setup.ts — run with: npx tsx scripts/verify-setup.ts
import { notify } from "../lib/notifykit"

async function main() {
  // 1. Create a test recipient
  await notify.upsertRecipient({ id: "test_user", email: "you@example.com" })

  // 2. Send a notification
  const result = await notify.send({
    recipientId: "test_user",
    notificationId: "comment_mentioned",
    payload: { actorName: "Setup Script", postUrl: "/test" },
  })

  // 3. Verify it worked
  const inbox = await notify.inbox.list("test_user")

  console.log("Deliveries:", result.deliveries.length)
  console.log("Inbox items:", inbox.length)
  console.log("First item:", inbox[0]?.title)
}

main()

Deliveries: 1+

At least one delivery record means the channel pipeline ran. If 0, check that your notification has channels defined.

Inbox items: 1

The inbox item was written. If 0, ensure your notification includes an inbox() channel.

Title matches template

"Setup Script mentioned you" confirms payload interpolation works. If raw {{actorName}} appears, check your template syntax.

This script uses the in-memory adapter — data disappears on restart. That's expected for dev. Once you see all three checks pass, your setup is correct end-to-end.

What to add next

You have a working setup — now grow it incrementally. Each step is independent; add them in any order as your app needs them:

1
Define your notifications

Create typed definitions with payloads and channel templates. You'll call send() against these.

2
Add inbox UI

Drop in useInbox() and useUnreadCount() for a notification bell. Works with zero config.

3
Swap to a real database

Install @notifykitjs/drizzle and switch to SQLite or Postgres. Data now survives restarts.

4
Connect an email provider

Install @notifykitjs/resend (or build a custom provider). Emails start reaching real inboxes.

When you needAdd thisDocs
Typed notification definitionsDefinitions in lib/notifykit.ts (split into modules as the app grows)Defining
In-app notification belluseInbox() + useUnreadCount()React hooks
Persistent state@notifykitjs/drizzle adapterDatabase
Real email delivery@notifykitjs/resend providerProviders
User opt-outsPreferences UI with usePreferences()Preferences
Noise controlrateLimit + digest on definitionsDigests
Multi-instance deploys@notifykitjs/realtime-pgRealtime
Each piece is additive. Adding a database doesn't change your notification definitions. Adding email doesn't change your inbox code. You can ship each step independently without touching what you built before.

Requirements

DependencyMinimumNotes
Node.js18+Or any runtime with fetch + crypto (Bun, Deno, Cloudflare Workers)
TypeScript5.0+Required for full type inference on send()
Next.js14+ (App Router)Only if using the handler and React bindings. Core works anywhere.
Not on Next.js? The route handler uses standard Web Request/Response — it works with Hono, Express (via adapter), SvelteKit, or any framework that supports the Fetch API.

Packages

PackagePurposeWhen to install
@notifykitjs/coreEngine, channels, providers, typesAlways
@notifykitjs/nextRoute handler, server actionsNext.js apps with client-facing API
@notifykitjs/reactHooks, components, client SDKBuilding notification UI in React
@notifykitjs/drizzleSQLite + Postgres adaptersPersisting state beyond in-memory
@notifykitjs/resendResend email providerSending real emails via Resend
@notifykitjs/realtime-pgPostgreSQL NOTIFY adapterMulti-instance deploys with Postgres
@notifykitjs/realtime-wsWebSocket adapterCustom transports, high connection counts
Minimum install: 3 packages. Most Next.js apps start with core + next + react. Add drizzle when you need persistence and a provider package when you're ready to send real emails.

Environment variables

NotifyKit needs very few environment variables. Here's a consolidated reference — copy into your .env.local and fill in values:

.env.local
# ─── Required ────────────────────────────────────────────
NOTIFYKIT_SECRET=           # 32-byte hex string (signs unsubscribe links)

# ─── Database (pick one) ─────────────────────────────────
DATABASE_URL=               # Postgres connection string (if using drizzlePostgresAdapter)
# Or: omit entirely for memoryAdapter() / SQLite file path

# ─── Email provider (pick one) ───────────────────────────
RESEND_API_KEY=             # If using @notifykitjs/resend
RESEND_FROM=                # Sender address: "App Name <noreply@app.com>"
# Or: omit for fakeEmailProvider() in dev

# ─── SMS provider (optional) ─────────────────────────────
TWILIO_ACCOUNT_SID=         # If using a Twilio-based SMS provider
TWILIO_AUTH_TOKEN=
TWILIO_FROM=                # Your Twilio phone number

# ─── Webhooks (optional) ─────────────────────────────────
WEBHOOK_SIGNING_SECRET=     # Shared secret for outbound webhook signatures
VariableRequiredUsed byFormat
NOTIFYKIT_SECRETYesUnsubscribe links, token signing64-char hex (32 bytes). Generate with node -e "..."
DATABASE_URLProduction onlyDrizzle Postgres adapterpostgresql://user:pass@host:5432/db
RESEND_API_KEYIf sending email@notifykitjs/resendre_... (from Resend dashboard)
RESEND_FROMIf sending emailresendProvider()"Name <email@domain>" (must be verified domain)
WEBHOOK_SIGNING_SECRETIf using webhookswebhookProvider()Any strong random string (32+ chars)

Per-environment setup

Match your env vars to your deployment stage. Most teams need three configurations:

StageDatabaseEmailSecret
Local devMemory (no DATABASE_URL)Fake (no RESEND_API_KEY)Any hex string — never leaves your machine
CI / TestsMemory (fastest)FakeHardcoded test value in CI config
StagingPostgres (shared staging DB)Resend test mode or a sandbox domainUnique per environment — rotate on compromise
ProductionPostgres (production DB)Resend with verified domainStored in secrets manager (Vercel, AWS SSM, Vault)
Never share NOTIFYKIT_SECRET across environments. Unsubscribe tokens signed with the staging secret are valid in staging only. If you reuse the same secret in production, a leaked staging token could unsubscribe production users. Generate a unique secret per environment.
// Pattern: environment-aware config in lib/notifykit.ts
import { createNotifyKit, memoryAdapter, fakeEmailProvider } from "@notifykitjs/core"
import { drizzlePostgresAdapter } from "@notifykitjs/drizzle"
import { resendProvider } from "@notifykitjs/resend"

const isProd = process.env.NODE_ENV === "production"

export const notify = createNotifyKit({
  notifications: [...] as const,

  database: isProd
    ? drizzlePostgresAdapter(db)
    : memoryAdapter(),

  providers: {
    email: process.env.RESEND_API_KEY
      ? resendProvider({ apiKey: process.env.RESEND_API_KEY, from: process.env.RESEND_FROM! })
      : fakeEmailProvider(),
  },

  unsubscribe: {
    secret: process.env.NOTIFYKIT_SECRET!,
    baseUrl: isProd
      ? "https://yourapp.com/api/notifykit"
      : "http://localhost:3000/api/notifykit",
  },
})
Feature-flag by env var presence, not by NODE_ENV. Checking process.env.RESEND_API_KEY means you can test with real email locally by adding the key to .env.local — without changing any code. Same adapter, different credentials.

Monorepo setup

In a monorepo (Turborepo, Nx, pnpm workspaces), your NotifyKit instance, route handler, and React UI typically live in separate packages. The key is putting notification definitions in a shared package so both server and client apps can import them without duplication.

PackageContainsDepends on
packages/notificationsNotifyKit instance, notification definitions, recipient helpers@notifykitjs/core, @notifykitjs/drizzle
apps/api (or apps/web)Route handler, server actions, identify()@notifykitjs/next, packages/notifications
apps/web (frontend)<NotifyKitProvider>, hooks, inbox UI@notifykitjs/react
apps/worker (optional)Background jobs that call notify.send()packages/notifications
// packages/notifications/src/index.ts
import { createNotifyKit, channel, notification } from "@notifykitjs/core"
import { drizzlePostgresAdapter } from "@notifykitjs/drizzle"
import { db } from "./db"

const inbox = channel.inbox()
const email = channel.email()

export const commentMentioned = notification({
  id: "comment_mentioned",
  payload: { actorName: "string", postUrl: "string" },
  channels: [
    inbox({ title: "{{actorName}} mentioned you" }),
    email({ subject: "{{actorName}} mentioned you", body: "Open {{postUrl}}" }),
  ],
})

export const notify = createNotifyKit({
  notifications: [commentMentioned] as const,
  database: drizzlePostgresAdapter(db),
  providers: { email: process.env.RESEND_API_KEY ? resendProvider({...}) : fakeEmailProvider() },
  unsubscribe: { secret: process.env.NOTIFYKIT_SECRET!, baseUrl: process.env.NOTIFYKIT_BASE_URL! },
})
// apps/web/app/api/notifykit/[...route]/route.ts
import { createRouteHandler } from "@notifykitjs/next"
import { notify } from "@acme/notifications"  // ← internal package
import { auth } from "@/lib/auth"

export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler({
  notifykit: notify,
  identify: async (request) => {
    const session = await auth(request)
    if (!session) return null
    return { recipientId: session.user.id, tenantId: session.orgId }
  },
})
Don't import @notifykitjs/core from the frontend. The shared packages/notifications package contains server-only code (database connections, secrets). Only the route handler and background workers should import it. The frontend only needs @notifykitjs/react and talks to NotifyKit through the REST API.
PitfallSymptomFix
Importing notify in a client componentBuild error: pg / crypto not available in browserOnly import from packages/notifications in server code. Frontend uses hooks.
Missing env vars in the shared packageNOTIFYKIT_SECRET is undefined at runtimeEnv vars resolve in the consuming app, not the package. Add them to each app's .env.
TypeScript path aliases don't resolveCannot find module '@acme/notifications'Configure transpilePackages in next.config.js or set up proper exports in the package's package.json.
Multiple NotifyKit instances across workersDedup and rate limits don't work (each instance has its own memory)With in-memory, all callers must share the same process. Use Postgres adapter for multi-process setups.
apps/web/next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
  // Tell Next.js to transpile the internal package
  transpilePackages: ["@acme/notifications"],
}
Test from the shared package. Write your notification unit tests (using explain() and memoryAdapter()) inside packages/notifications. They run without starting any app — fast feedback on routing logic, dedup keys, and preference resolution.

Troubleshooting setup

Stuck during installation? These are the most common issues and their one-line fixes:

Error / symptomCauseFix
Cannot find module '@notifykitjs/core'Package not installed or wrong workspaceRun npm install @notifykitjs/core in the correct directory
NOTIFYKIT_SECRET is undefinedMissing .env.local or env not loadedCreate .env.local with NOTIFYKIT_SECRET=<32-byte hex>. Restart the dev server.
TypeError: notify.send is not a functionImporting the config object instead of the instanceMake sure you export and import the result of createNotifyKit(), not the options object
Route returns 405 Method Not AllowedMissing HTTP method exportExport all methods: export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler(...)
Error: No notifications registeredEmpty or non-const notification arrayPass at least one notification and use as const: notifications: [...] as const
TypeScript: Argument not assignable to parameter on send()Missing as const on the notifications arrayAdd as const — without it, TypeScript widens IDs to string and loses type safety
SSE connection drops immediatelyNext.js static optimization caching the routeExport dynamic from the route handler — it sets export const dynamic = 'force-dynamic'
Hooks return status: "error" with 401identify() can't read the session cookieVerify cookies are sent — check credentials: "include" and same-origin. Cross-origin needs CORS config.
Generate a secret in one line: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" — paste the output into .env.local. Never commit secrets to git.

Checklist before asking for help

CheckCommandExpected
Packages installednpm ls @notifykitjs/coreShows version number, no MISSING
Env loadedecho $NOTIFYKIT_SECRET (or check Next.js log)Non-empty 64-char hex string
Route handler respondingcurl http://localhost:3000/api/notifykit/notificationsJSON object with a data array (even if empty)
TypeScript compilesnpx tsc --noEmitNo errors in notifykit files