Installation
Add NotifyKit to an existing TypeScript app, or start from a Next.js app with everything already wired up.
Requirements
| Version | Notes | |
|---|---|---|
| Node.js | 18 or later | The core uses node:crypto and node:dns, so it runs on Node or a runtime compatible with those modules, such as Bun |
| TypeScript | 5.0 or later | Needed for the inferred ids and payload types on send() |
| Next.js | 14 or later | Only for @notifykitjs/next. The core and the HTTP handler work in any server |
| React | 18 or later | Only for @notifykitjs/react |
New App
create-notifykit-app writes a small Next.js app with an inbox, a preferences page, and a form that sends you a test notification:
npx create-notifykit-app my-app
cd my-app
cp .env.example .env.local
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# paste the output into .env.local as NOTIFYKIT_SECRET
npm install
npm run devIt keeps everything in memory and uses a fake email provider, so it runs with no database and no API keys. The Quickstart walks through it. The files you will edit:
| File | What it holds |
|---|---|
lib/notifykit.ts | The notification definitions and the NotifyKit instance. Start here |
app/api/notifykit/[...route]/route.ts | The HTTP handler for the inbox, preferences, and unsubscribe routes, with identify() |
lib/session.ts | A demo sign-in cookie. Replace it with your real auth |
app/layout.tsx | Wraps the app in <NotifyKitProvider> |
app/_components/inbox-view.tsx | The bell and inbox list |
app/settings/notifications/ | The preferences table |
.env.example | NOTIFYKIT_SECRET, NOTIFYKIT_BASE_URL, and commented-out Resend settings |
Existing App
In a Next.js app, install the core, the Next.js helpers, and the React bindings:
npm install @notifykitjs/core @notifykitjs/next @notifykitjs/reactbun add @notifykitjs/core @notifykitjs/next @notifykitjs/reactpnpm add @notifykitjs/core @notifykitjs/next @notifykitjs/reactThen add three files. First the definitions and the instance:
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", actionUrl: "{{postUrl}}" }),
email({
subject: "{{actorName}} mentioned you",
body: "Open {{postUrl}} to reply.\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",
},
})Then the route handler, which serves the browser-facing API for whoever is signed in:
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,
})And the provider, so the React hooks know where that API lives:
import { NotifyKitProvider } from "@notifykitjs/react"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<NotifyKitProvider options={{ baseUrl: "/api/notifykit" }}>
{children}
</NotifyKitProvider>
</body>
</html>
)
}identify() is the only thing tying the routes to a user: return the signed-in user’s id, or null to answer 401. The handler never trusts a recipient id sent by the browser. See Next.js and Security model.
Verify
With the dev server running, the notifications route should list what you registered:
curl http://localhost:3000/api/notifykit/notifications{ "data": [{ "id": "comment_mentioned", "channels": ["inbox", "email"], ... }] }A 404 means the route file is in the wrong place; it must be app/api/notifykit/[...route]/route.ts. A 500 is almost always a startup error, printed in the terminal. Then try the whole pipeline from a script:
import { notify } from "../lib/notifykit"
await notify.upsertRecipient({ id: "test_user", email: "you@example.com" })
const result = await notify.send({
recipientId: "test_user",
notificationId: "comment_mentioned",
payload: { actorName: "Setup script", postUrl: "/test" },
})
console.log(result.inboxItems[0]?.title) // "Setup script mentioned you"
console.log(result.deliveries[0]?.status) // "sent"Run it with npx tsx scripts/verify.ts. The inbox item is the inbox channel; the delivery is the email, accepted by the fake provider. With the memory adapter, all of this is gone when the process exits.
Environment
NotifyKit reads no environment variables itself; these are the ones the examples pass in:
| Variable | Used for |
|---|---|
NOTIFYKIT_SECRET | Signing unsubscribe links (unsubscribe.secret) and verifying them (unsubscribeSecret). At least 32 characters; the command above makes 64 |
NOTIFYKIT_BASE_URL | The absolute URL the handler is mounted at, used in unsubscribe links (unsubscribe.baseUrl) |
RESEND_API_KEY | resendProvider(), if you send email through Resend |
RESEND_FROM | The sender, such as Acme <no-reply@acme.com>, on a domain verified with Resend |
DATABASE_URL | Your own Postgres connection, which you hand to Drizzle |
A common pattern is to pick real services when their credentials are present, so you can try real email locally just by adding a key:
export const notify = createNotifyKit({
notifications: [commentMentioned] as const,
database: process.env.DATABASE_URL
? 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: process.env.NOTIFYKIT_BASE_URL!,
},
})Some provider is always required: a notification with an email channel and no email provider is a startup error. See Database adapters and Email and webhook providers.
Monorepos
Put the definitions and the notify instance in a shared server-only package, and import it from the route handler and any workers. The frontend only needs @notifykitjs/react and talks to NotifyKit over HTTP.
packages/notifications: definitions and the instance, depending on@notifykitjs/coreand your adapterapps/web: the route handler withidentify(), and the provider and hooksapps/worker: background jobs that callnotify.send()
- Never import the shared package from client components: it holds database connections and secrets.
- Environment variables are read by the app that runs the code, so each app needs them in its own
.env. - If Next.js cannot resolve the package, add it to
transpilePackagesinnext.config, or give the package properexports. - The memory adapter is per process. Dedupe, rate limits, and digests only work across processes with a shared database adapter.
Troubleshooting
| Symptom | Fix |
|---|---|
Invalid NotifyKit configuration | createNotifyKit() checks every definition at startup and lists all problems at once, each with a fix. Common ones: a template variable missing from the payload, an email channel with no email provider, or a secret under 32 characters |
Unknown recipient | Call upsertRecipient() before the first send() to that user |
Unknown notification id | The id is not in the notifications array. The error lists the registered ids |
| TypeScript accepts any id or payload | Add as const after the notifications array; without it the ids widen to string |
| 405 or missing methods | Export GET, POST, DELETE, OPTIONS, and dynamic from the route |
| Hooks report 401 | identify() returned null. Check that the session cookie reaches the route; cross-origin setups need cors on the handler and credentials on the client |
Where next
- Defining notifications: payloads, templates, and options
- React: build the bell and inbox
- Database adapters: keep state across restarts