Quickstart
Scaffold an app, send your first notification, and watch it arrive in the inbox and as an email in about five minutes.
Before You Begin
You need Node.js 18 or later and npm. Nothing else: the starter app keeps everything in memory and uses a fake email provider, so there is no database to create and no API key to get.
- 01
Create the app
npx create-notifykit-app my-app cd my-app cp .env.example .env.localUnsubscribe links are signed with a secret. Generate one and paste it into
.env.localasNOTIFYKIT_SECRET, replacing the placeholder:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"The app refuses to start while the placeholder is still there. Then install and run it:
npm install npm run dev - 02
Try it
Open
http://localhost:3000and choose Sign in as demo user. Fill in the form to send yourself a notification. It shows up in the inbox below, and the bell’s unread count goes up. Then open Manage your notification preferences and turn a channel off.Each send also produces an email. The fake provider accepts it and keeps it in memory instead of sending it, so nothing arrives in a real mailbox.
- 03
Read lib/notifykit.ts
Everything starts in one file. It defines the notification the form sends and creates the
notifyinstance the rest of the app imports:lib/notifykit.tsconst inbox = channel.inbox() const email = channel.email() export const commentMentioned = notification({ id: "comment_mentioned", payload: { actorName: "string", postTitle: "string", postUrl: "string" }, channels: [ inbox({ title: "{{actorName}} mentioned you", body: "In {{postTitle}}", actionUrl: "{{postUrl}}", }), email({ subject: "{{actorName}} mentioned you in {{postTitle}}", body: `{{actorName}} mentioned you in "{{postTitle}}". ` + "Open {{postUrl}} to reply.", }), ], }) export const notify = createNotifyKit({ notifications: [commentMentioned] as const, database: memoryAdapter(), providers: { email: fakeEmailProvider() }, unsubscribe, // { secret, baseUrl } from .env.local })That is abridged: the real file also checks the secret and seeds the demo user.
The
payloaddeclares the data each send must include. Channel templates fill in{{placeholders}}from it, and TypeScript checks everysend()against it. - 04
Add a notification
Define a second one below the first and register it:
lib/notifykit.tsexport const taskAssigned = notification({ id: "task_assigned", payload: { assignerName: "string", taskTitle: "string", taskUrl: "string" }, channels: [ inbox({ title: "{{assignerName}} assigned you a task", body: "{{taskTitle}}", actionUrl: "{{taskUrl}}", }), email({ subject: "New task: {{taskTitle}}", body: "{{assignerName}} assigned you {{taskTitle}}. " + "Open it: {{taskUrl}}", }), ], }) export const notify = createNotifyKit({ notifications: [commentMentioned, taskAssigned] as const, // ... })Save, and the new notification appears on the preferences page. A template that uses a field the payload does not declare is caught when the app starts, with the fix in the error.
- 05
Send it
Call
notify.send()from server code with a recipient, the notification id, and its payload. A misspelled id or a missing field is a type error:const result = await notify.send({ recipientId: "demo_user", notificationId: "task_assigned", payload: { assignerName: "Rey", taskTitle: "Fix the auth bug", taskUrl: "/tasks/42", }, }) result.inboxItems // the inbox row that was written result.deliveries // the email, with status "sent" result.skipped // channels that did not go out, and whyTurn email off for this notification on the preferences page and send again:
result.skippednow lists email with the reason"preferences_disabled", while the inbox item is still written.
Going to Production
Three things change before real users see it:
- The database. Replace
memoryAdapter()with the Drizzle adapter for SQLite or Postgres. See Database adapters. - The email provider. Replace
fakeEmailProvider()withresendProvider()or an SMTP provider. See Email and webhook providers. - Auth.
lib/session.tsreads an unsigned demo cookie that anyone can forge. Makeidentify()return the user from your real session. See Next.js.
import { drizzlePostgresAdapter } from "@notifykitjs/drizzle"
import { resendProvider } from "@notifykitjs/resend"
export const notify = createNotifyKit({
notifications: [commentMentioned, taskAssigned] as const,
database: drizzlePostgresAdapter(db),
providers: {
email: resendProvider({
apiKey: process.env.RESEND_API_KEY!,
from: process.env.RESEND_FROM!,
}),
},
unsubscribe: {
secret: process.env.NOTIFYKIT_SECRET!,
baseUrl: "https://yourapp.com/api/notifykit",
},
})The definitions, the route handler, and the React code stay the same. Before relying on it for anything critical, read Production readiness: the default queue is not durable.
First-Run Problems
| Symptom | Fix |
|---|---|
| Error about the placeholder secret | Generate a secret with the command in step 1 and replace NOTIFYKIT_SECRET in .env.local |
Unknown recipient | The recipient has to exist first. The starter creates demo_user when you sign in; for other users call upsertRecipient() |
| No email arrived | Expected: the fake provider keeps emails in memory. Switch to a real provider to send them |
| A new send is missing from the inbox | The send ran in a different module instance than the handler; see the note in step 5 |
Invalid NotifyKit configuration | A definition has a problem. The error lists each one with a fix |
For anything else, notify.explain() runs the same checks as send() without writing or sending anything, and says what each channel would do. See Explain and dry run.
Where Next
- Defining notifications: payload schemas, templates, and options
- React: build your own bell, inbox, and preferences page
- Preferences and unsubscribe: defaults, categories, and required notifications
- Digests and rate limits: keep busy notifications from flooding people