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 devOpen 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:
| File | What it does | Edit when |
|---|---|---|
lib/notifykit.ts | NotifyKit instance + notification definitions | Adding notifications, swapping database/provider |
app/_components/demo-sender.tsx | Demo form that sends through the local handler runtime | Replacing the demo flow with your app's real send trigger |
app/api/notifykit/[...route]/route.ts | REST handler — inbox, preferences, SSE, unsubscribe | Changing auth, adding CORS, protecting routes |
app/layout.tsx | <NotifyKitProvider> wrapper | Changing baseUrl or conditional rendering |
app/settings/notifications/page.tsx | Preferences UI with per-channel toggles | Customizing the settings layout or adding categories |
app/_components/inbox-view.tsx | Bell count + inbox list | Styling the inbox or customizing item rendering |
.env.example | Required env vars with comments | Adding provider API keys for production |
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/reactA minimal setup needs three files:
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",
},
})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,
})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/notificationsYou should see a JSON response containing your registered notifications:
{ "data": [{ "id": "comment_mentioned", "channels": ["inbox", "email"], ... }] }| What you see | What it means | If it fails |
|---|---|---|
JSON with a data array containing your notification IDs | Handler is wired up and NotifyKit instance is loading | — |
404 | Route not found | Check the file is at app/api/notifykit/[...route]/route.ts |
500 / module error | Import or config issue | Check 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.
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:
Create typed definitions with payloads and channel templates. You'll call send() against these.
Drop in useInbox() and useUnreadCount() for a notification bell. Works with zero config.
Install @notifykitjs/drizzle and switch to SQLite or Postgres. Data now survives restarts.
Install @notifykitjs/resend (or build a custom provider). Emails start reaching real inboxes.
| When you need | Add this | Docs |
|---|---|---|
| Typed notification definitions | Definitions in lib/notifykit.ts (split into modules as the app grows) | Defining |
| In-app notification bell | useInbox() + useUnreadCount() | React hooks |
| Persistent state | @notifykitjs/drizzle adapter | Database |
| Real email delivery | @notifykitjs/resend provider | Providers |
| User opt-outs | Preferences UI with usePreferences() | Preferences |
| Noise control | rateLimit + digest on definitions | Digests |
| Multi-instance deploys | @notifykitjs/realtime-pg | Realtime |
Requirements
| Dependency | Minimum | Notes |
|---|---|---|
| Node.js | 18+ | Or any runtime with fetch + crypto (Bun, Deno, Cloudflare Workers) |
| TypeScript | 5.0+ | Required for full type inference on send() |
| Next.js | 14+ (App Router) | Only if using the handler and React bindings. Core works anywhere. |
Request/Response — it works with Hono, Express (via adapter), SvelteKit, or any framework that supports the Fetch API.Packages
| Package | Purpose | When to install |
|---|---|---|
@notifykitjs/core | Engine, channels, providers, types | Always |
@notifykitjs/next | Route handler, server actions | Next.js apps with client-facing API |
@notifykitjs/react | Hooks, components, client SDK | Building notification UI in React |
@notifykitjs/drizzle | SQLite + Postgres adapters | Persisting state beyond in-memory |
@notifykitjs/resend | Resend email provider | Sending real emails via Resend |
@notifykitjs/realtime-pg | PostgreSQL NOTIFY adapter | Multi-instance deploys with Postgres |
@notifykitjs/realtime-ws | WebSocket adapter | Custom transports, high connection counts |
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:
# ─── 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| Variable | Required | Used by | Format |
|---|---|---|---|
NOTIFYKIT_SECRET | Yes | Unsubscribe links, token signing | 64-char hex (32 bytes). Generate with node -e "..." |
DATABASE_URL | Production only | Drizzle Postgres adapter | postgresql://user:pass@host:5432/db |
RESEND_API_KEY | If sending email | @notifykitjs/resend | re_... (from Resend dashboard) |
RESEND_FROM | If sending email | resendProvider() | "Name <email@domain>" (must be verified domain) |
WEBHOOK_SIGNING_SECRET | If using webhooks | webhookProvider() | Any strong random string (32+ chars) |
Per-environment setup
Match your env vars to your deployment stage. Most teams need three configurations:
| Stage | Database | Secret | |
|---|---|---|---|
| Local dev | Memory (no DATABASE_URL) | Fake (no RESEND_API_KEY) | Any hex string — never leaves your machine |
| CI / Tests | Memory (fastest) | Fake | Hardcoded test value in CI config |
| Staging | Postgres (shared staging DB) | Resend test mode or a sandbox domain | Unique per environment — rotate on compromise |
| Production | Postgres (production DB) | Resend with verified domain | Stored in secrets manager (Vercel, AWS SSM, Vault) |
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",
},
})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.
| Package | Contains | Depends on |
|---|---|---|
packages/notifications | NotifyKit 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 }
},
})@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.| Pitfall | Symptom | Fix |
|---|---|---|
Importing notify in a client component | Build error: pg / crypto not available in browser | Only import from packages/notifications in server code. Frontend uses hooks. |
| Missing env vars in the shared package | NOTIFYKIT_SECRET is undefined at runtime | Env vars resolve in the consuming app, not the package. Add them to each app's .env. |
| TypeScript path aliases don't resolve | Cannot 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 workers | Dedup 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. |
/** @type {import('next').NextConfig} */
module.exports = {
// Tell Next.js to transpile the internal package
transpilePackages: ["@acme/notifications"],
}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 / symptom | Cause | Fix |
|---|---|---|
Cannot find module '@notifykitjs/core' | Package not installed or wrong workspace | Run npm install @notifykitjs/core in the correct directory |
NOTIFYKIT_SECRET is undefined | Missing .env.local or env not loaded | Create .env.local with NOTIFYKIT_SECRET=<32-byte hex>. Restart the dev server. |
TypeError: notify.send is not a function | Importing the config object instead of the instance | Make sure you export and import the result of createNotifyKit(), not the options object |
Route returns 405 Method Not Allowed | Missing HTTP method export | Export all methods: export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler(...) |
Error: No notifications registered | Empty or non-const notification array | Pass 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 array | Add as const — without it, TypeScript widens IDs to string and loses type safety |
| SSE connection drops immediately | Next.js static optimization caching the route | Export dynamic from the route handler — it sets export const dynamic = 'force-dynamic' |
Hooks return status: "error" with 401 | identify() can't read the session cookie | Verify cookies are sent — check credentials: "include" and same-origin. Cross-origin needs CORS config. |
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
| Check | Command | Expected |
|---|---|---|
| Packages installed | npm ls @notifykitjs/core | Shows version number, no MISSING |
| Env loaded | echo $NOTIFYKIT_SECRET (or check Next.js log) | Non-empty 64-char hex string |
| Route handler responding | curl http://localhost:3000/api/notifykit/notifications | JSON object with a data array (even if empty) |
| TypeScript compiles | npx tsc --noEmit | No errors in notifykit files |