BackGetting started

Installation

Add NotifyKit to an existing TypeScript app, or start from a Next.js app with everything already wired up.

Requirements

VersionNotes
Node.js18 or laterThe core uses node:crypto and node:dns, so it runs on Node or a runtime compatible with those modules, such as Bun
TypeScript5.0 or laterNeeded for the inferred ids and payload types on send()
Next.js14 or laterOnly for @notifykitjs/next. The core and the HTTP handler work in any server
React18 or laterOnly 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 dev

It 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:

FileWhat it holds
lib/notifykit.tsThe notification definitions and the NotifyKit instance. Start here
app/api/notifykit/[...route]/route.tsThe HTTP handler for the inbox, preferences, and unsubscribe routes, with identify()
lib/session.tsA demo sign-in cookie. Replace it with your real auth
app/layout.tsxWraps the app in <NotifyKitProvider>
app/_components/inbox-view.tsxThe bell and inbox list
app/settings/notifications/The preferences table
.env.exampleNOTIFYKIT_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/react

Then add three files. First the definitions and the instance:

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", 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:

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,
})

And the provider, so the React hooks know where that API lives:

app/layout.tsx
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:

scripts/verify.ts
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:

VariableUsed for
NOTIFYKIT_SECRETSigning unsubscribe links (unsubscribe.secret) and verifying them (unsubscribeSecret). At least 32 characters; the command above makes 64
NOTIFYKIT_BASE_URLThe absolute URL the handler is mounted at, used in unsubscribe links (unsubscribe.baseUrl)
RESEND_API_KEYresendProvider(), if you send email through Resend
RESEND_FROMThe sender, such as Acme <no-reply@acme.com>, on a domain verified with Resend
DATABASE_URLYour 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:

lib/notifykit.ts
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/core and your adapter
  • apps/web: the route handler with identify(), and the provider and hooks
  • apps/worker: background jobs that call notify.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 transpilePackages in next.config, or give the package proper exports.
  • The memory adapter is per process. Dedupe, rate limits, and digests only work across processes with a shared database adapter.

Troubleshooting

SymptomFix
Invalid NotifyKit configurationcreateNotifyKit() 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 recipientCall upsertRecipient() before the first send() to that user
Unknown notification idThe id is not in the notifications array. The error lists the registered ids
TypeScript accepts any id or payloadAdd as const after the notifications array; without it the ids widen to string
405 or missing methodsExport GET, POST, DELETE, OPTIONS, and dynamic from the route
Hooks report 401identify() 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