Quickstart
Get a working notification system in under 5 minutes. This guide uses the starter scaffold — a Next.js app with everything wired up.
Create the app
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 as NOTIFYKIT_SECRET in .env.local
npm install
npm run devOpen http://localhost:3000. The scaffold uses the in-memory adapter and a fake email provider — everything works offline.
| Step | Takes | You know it worked when |
|---|---|---|
npx create-notifykit-app | ~10s | Directory created with package.json |
npm install | ~15s | No errors in terminal, node_modules exists |
npm run dev | ~3s | Terminal shows Ready on http://localhost:3000 |
| Open browser | Instant | You see the scaffold homepage with an inbox UI and a "Send test" button |
What you get
Notification definitions
lib/notifykit.ts with typed payloads and channel configs ready to edit.
API route handler
/api/notifykit/[...route] — inbox, preferences, and unsubscribe endpoints wired up.
Preferences UI
A settings page where users toggle channels per notification, with optimistic updates.
Realtime inbox
New notifications appear instantly. Read/unread, archive, and delete out of the box.
Signed unsubscribes
HMAC-SHA256 one-click unsubscribe links in every email. RFC 8058 compliant.
Works offline
In-memory adapter + fake email provider. No API keys, no database setup for local dev.
Scaffold file tree
The scaffold creates a small, focused structure. Here's where everything lives — you'll edit 2–3 of these files in the first session:
my-app/
├── app/
│ ├── api/
│ │ └── notifykit/
│ │ └── [...notifykit]/
│ │ └── route.ts ← Handler: auth + all REST endpoints
│ ├── layout.tsx ← Wraps app in <NotifyKitProvider>
│ ├── page.tsx ← Demo page with "Send test" button
│ └── settings/
│ └── notifications/
│ └── page.tsx ← Preferences UI (channel toggles)
├── components/
│ └── inbox.tsx ← Notification bell + item list
├── lib/
│ ├── notifykit.ts ← START HERE: definitions + config
│ └── auth.ts ← identify() wiring (stub in scaffold)
├── .env.example
├── .env.local ← Your NOTIFYKIT_SECRET
└── package.json| File | You'll edit it to | Guide |
|---|---|---|
lib/notifykit.ts | Add notifications, change channels, swap providers | Defining |
api/.../route.ts | Wire real auth into identify() | Next.js integration |
components/inbox.tsx | Style the bell, add filtering, customize layout | React hooks |
lib/auth.ts | Replace the stub with your session library | Security model |
lib/notifykit.ts. It's a single file with your notification definitions, database adapter, and provider. Every change you make to notifications starts there — the scaffold imports it everywhere else.Send your first notification
From a server action, API route, or anywhere on the server:
import { notify } from "@/lib/notifykit"
await notify.upsertRecipient({
id: user.id,
email: user.email,
name: user.name,
})
const result = await notify.send({
recipientId: user.id,
notificationId: "welcome",
payload: { name: user.name },
})
console.log(result.inboxItems) // inbox row created
console.log(result.deliveries) // email delivery recordWhat just happened
That single send() triggered the full pipeline. Here's the path your notification took through the engine:
{ name: user.name } checked against the notification schema. Passes.
No opt-outs configured — inbox and email both allowed.
Channel templates interpolate the payload into a title, body, and email HTML.
Inbox row written to the database. Email handed to the provider (fake in dev).
And here's what the result object tells you:
| Field | You'll see | It means |
|---|---|---|
result.notification | A NotificationRecord with an ID | The send was recorded — you can look it up later via timeline |
result.inboxItems[0] | An InboxItem with title, body, timestamps | The inbox channel wrote a row — the React hooks will pick it up |
result.deliveries[0] | A DeliveryRecord with status: "sent" | Email was queued and the (fake) provider confirmed delivery |
result.skipped | Empty array [] | No channels were blocked — preferences allow everything by default |
/settings/notifications in the scaffold, uncheck email for the "welcome" notification, then send again. You'll see result.skipped include email with reason "preferences_disabled" — the pipeline is working.See it in your inbox
You sent a notification server-side — now display it in the UI. The scaffold already has this wired up, but here's what the code looks like so you can add it anywhere in your app:
"use client"
import { useInbox, useUnreadCount } from "@notifykitjs/react"
function NotificationBell() {
const { unreadCount } = useUnreadCount()
const { items, markAsRead, archive } = useInbox()
return (
<div>
<button>
🔔 {unreadCount > 0 && <span>{unreadCount}</span>}
</button>
<ul>
{items.map((item) => (
<li key={item.id} style={{ opacity: item.readAt ? 0.6 : 1 }}>
<strong>{item.title}</strong>
<p>{item.body}</p>
{!item.readAt && (
<button onClick={() => markAsRead(item.id)}>Mark read</button>
)}
<button onClick={() => archive(item.id)}>Archive</button>
</li>
))}
</ul>
</div>
)
}| Hook | Returns | Updates when |
|---|---|---|
useUnreadCount() | Live count of unread items | New notification arrives, item marked read, mark-all-read |
useInbox() | Items array + mutation methods (markAsRead, archive, delete) | SSE event arrives — no polling, no manual refresh |
send() fires on the server, the inbox component updates instantly via SSE — no page reload needed. Mutations are optimistic: the UI updates immediately, then syncs with the server. See React hooks for the full API.Add your own notification
The scaffold includes a demo notification. Here's how to add a real one that matches your app — the single edit you'll make in your first session:
Add a notification to lib/notifykit.ts with your payload fields and channel templates.
Add it to the notifications array so the engine knows about it.
Call send() from your server code. TypeScript enforces the correct payload shape.
// lib/notifykit.ts — add below existing definitions:
export 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}}'.\n\nOpen it: {{taskUrl}}\n\nUnsubscribe: {{_unsubscribeUrl}}",
}),
],
})
// Register it:
export const notify = createNotifyKit({
notifications: [commentMentioned, taskAssigned] as const,
// ^^^^^^^^^^^ add here
...
})// Anywhere on the server — send it:
import { notify } from "@/lib/notifykit"
await notify.send({
recipientId: assignee.id,
notificationId: "task_assigned",
// ^^^^^^^^^^^^^^^ autocomplete shows your registered IDs
payload: {
assignerName: currentUser.name,
taskTitle: "Fix auth bug",
taskUrl: "/tasks/42",
},
})
// TypeScript error if you misspell a field or miss one| What to decide | Your options | Start with |
|---|---|---|
| Which channels? | Inbox, email, SMS, webhook — in any combination | Inbox + email covers most use cases |
| What payload fields? | Any strings/numbers your templates reference | Actor name + action URL is the minimum useful set |
| Can users opt out? | required: false (default) or required: true | Leave as default — only force security/transactional notifications |
{{dueDate}} to your template but forget to add dueDate to the payload schema, TypeScript errors at send() time — not at runtime when a user gets a blank notification. See Defining notifications for advanced patterns like categories, conditional rendering, and i18n.Go to production
When you're ready to ship, swap three things:
Replace memoryAdapter() with Drizzle SQLite or Postgres.
Replace fakeEmailProvider() with Resend, Postmark, or your own.
Wire your real session into identify() in the route handler.
Here's the concrete diff in lib/notifykit.ts:
// BEFORE (dev — in-memory, fake email):
import {
createNotifyKit, memoryAdapter, fakeEmailProvider, channel, notification,
} from "@notifykitjs/core"
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" },
})
// AFTER (production — Drizzle + Resend):
import { createNotifyKit, channel, notification } from "@notifykitjs/core"
import { drizzlePostgresAdapter } from "@notifykitjs/drizzle"
import { resendProvider } from "@notifykitjs/resend"
import { db } from "@/lib/db"
export const notify = createNotifyKit({
notifications: [commentMentioned] as const,
database: drizzlePostgresAdapter(db),
providers: { email: resendProvider({ apiKey: process.env.RESEND_API_KEY!, from: "notifications@yourapp.com" }) },
unsubscribe: { secret: process.env.NOTIFYKIT_SECRET!, baseUrl: "https://yourapp.com/api/notifykit" },
})| What changed | Dev value | Production value | New package |
|---|---|---|---|
database | memoryAdapter() | drizzlePostgresAdapter(db) | @notifykitjs/drizzle |
providers.email | fakeEmailProvider() | resendProvider({...}) | @notifykitjs/resend |
unsubscribe.baseUrl | http://localhost:3000/... | https://yourapp.com/... | — |
identify() | Demo user / hardcoded | Your real auth session | — |
Remove the scaffold demo
Once you've added your own notifications, remove the scaffold's demo code. Here's what to delete and what's safe to leave:
| File / code | Action | Why |
|---|---|---|
app/page.tsx (demo page with "Send test" button) | Delete or replace with your own homepage | It's just a demo UI — no other code depends on it |
The demo notification definition (e.g. welcome) | Remove from notifications array | Once removed, it stops appearing in preferences UI and can't be sent |
fakeEmailProvider() import | Remove after swapping to a real provider | Dead code — won't cause errors but clutters the file |
| Existing demo inbox items in the database | Leave them (harmless) or clear with a script | Old items stay visible in the inbox until archived or deleted. They don't affect new notifications. |
| Preference rows for the removed notification | Leave them (ignored automatically) | Preferences for unknown notification IDs are silently skipped — no cleanup needed |
send() calls that reference it. Existing inbox items from that notification remain visible (they're just data), and orphaned preferences are harmlessly ignored. No migration script needed. See Defining notifications for the full change-safety matrix.Common first-run issues
Most quickstart problems fall into one of these categories. Check the table before searching — the fix is usually one line:
| Symptom | Cause | Fix |
|---|---|---|
NOTIFYKIT_SECRET is required on startup | Missing or empty .env.local | Run the node -e "..." command above and paste the output as NOTIFYKIT_SECRET |
| Inbox shows no items after clicking "Send test" | Browser is on a different port than the API | Ensure you're on localhost:3000 (not 3001). The provider points at port 3000 by default. |
NotifyKitNotFoundError: Recipient not found | upsertRecipient() wasn't called before send() | Always create the recipient first — see the "Send your first notification" example above |
TypeScript error on notificationId | The ID doesn't match any registered definition | Check the exact id string in your notification definitions — it's case-sensitive |
Module not found: @notifykitjs/core | npm install didn't complete or ran in wrong directory | Run npm install again from the project root (where package.json is) |
| Email "sent" but nothing in your inbox | The scaffold uses fakeEmailProvider() — it logs, not sends | Expected! Check terminal output for the logged email. Swap to a real provider for actual delivery. |
| Preferences page shows empty list | No notifications registered or provider not wrapping the app | Verify <NotifyKitProvider> wraps your layout and notifications array isn't empty |
await notify.explain({...your send input}) from a server file. It shows the full pipeline resolution — preferences, quiet hours, channels — without writing any records. See Explain & dry run.Verifying each layer works
If you're unsure where the problem is, test each layer in isolation. This eliminates guesswork:
notify?Add console.log(notify.definitions.length) to a server file. If it prints a number, the instance is configured correctly.
Visit /api/notifykit/notifications in your browser. You should see a JSON object with notification metadata in its data array.
Open React DevTools → search for NotifyKitProvider. If it's not in the tree, hooks will return empty state.
Log the full result object. Check result.inboxItems (should have items) and result.skipped (should explain any missing channels).
What to try next
| Goal | Page | What you'll learn |
|---|---|---|
| Add a second notification type | Defining | Payload schemas, categories, optional fields |
| Let users opt out of email | Preferences | Per-channel toggles, unsubscribe links |
| Batch noisy events | Digests | Window-based coalescing with render() |
| Show a live notification bell | React hooks | useInbox(), realtime updates |
| Understand why something was skipped | Explain | Dry-run a send and inspect the resolution trail |