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 dev

Open http://localhost:3000. The scaffold uses the in-memory adapter and a fake email provider — everything works offline.

StepTakesYou know it worked when
npx create-notifykit-app~10sDirectory created with package.json
npm install~15sNo errors in terminal, node_modules exists
npm run dev~3sTerminal shows Ready on http://localhost:3000
Open browserInstantYou 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
FileYou'll edit it toGuide
lib/notifykit.tsAdd notifications, change channels, swap providersDefining
api/.../route.tsWire real auth into identify()Next.js integration
components/inbox.tsxStyle the bell, add filtering, customize layoutReact hooks
lib/auth.tsReplace the stub with your session librarySecurity model
Start in 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 record

What just happened

That single send() triggered the full pipeline. Here's the path your notification took through the engine:

1
Validate payload

{ name: user.name } checked against the notification schema. Passes.

2
Resolve preferences

No opt-outs configured — inbox and email both allowed.

3
Render templates

Channel templates interpolate the payload into a title, body, and email HTML.

4
Deliver

Inbox row written to the database. Email handed to the provider (fake in dev).

Stages that didn't fire: dedup, rate limit, digest, and quiet hours are all off by default. They activate when you configure them on the notification definition. See the full pipeline overview for all 10 stages.

And here's what the result object tells you:

FieldYou'll seeIt means
result.notificationA NotificationRecord with an IDThe send was recorded — you can look it up later via timeline
result.inboxItems[0]An InboxItem with title, body, timestampsThe 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.skippedEmpty array []No channels were blocked — preferences allow everything by default
Try disabling email. Open /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>
  )
}
HookReturnsUpdates when
useUnreadCount()Live count of unread itemsNew notification arrives, item marked read, mark-all-read
useInbox()Items array + mutation methods (markAsRead, archive, delete)SSE event arrives — no polling, no manual refresh
Both hooks update in real time. When 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:

1
Define

Add a notification to lib/notifykit.ts with your payload fields and channel templates.

2
Register

Add it to the notifications array so the engine knows about it.

3
Send

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 decideYour optionsStart with
Which channels?Inbox, email, SMS, webhook — in any combinationInbox + email covers most use cases
What payload fields?Any strings/numbers your templates referenceActor name + action URL is the minimum useful set
Can users opt out?required: false (default) or required: trueLeave as default — only force security/transactional notifications
Type safety catches mistakes instantly. If you add a {{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:

1
Database

Replace memoryAdapter() with Drizzle SQLite or Postgres.

2
Email provider

Replace fakeEmailProvider() with Resend, Postmark, or your own.

3
Auth

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 changedDev valueProduction valueNew package
databasememoryAdapter()drizzlePostgresAdapter(db)@notifykitjs/drizzle
providers.emailfakeEmailProvider()resendProvider({...})@notifykitjs/resend
unsubscribe.baseUrlhttp://localhost:3000/...https://yourapp.com/...
identify()Demo user / hardcodedYour real auth session
That's it. No migration scripts, no config files, no environment matrix. The same code that worked locally works in prod with real providers. Notification definitions, channel templates, and all pipeline behavior stay identical.

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 / codeActionWhy
app/page.tsx (demo page with "Send test" button)Delete or replace with your own homepageIt's just a demo UI — no other code depends on it
The demo notification definition (e.g. welcome)Remove from notifications arrayOnce removed, it stops appearing in preferences UI and can't be sent
fakeEmailProvider() importRemove after swapping to a real providerDead code — won't cause errors but clutters the file
Existing demo inbox items in the databaseLeave them (harmless) or clear with a scriptOld items stay visible in the inbox until archived or deleted. They don't affect new notifications.
Preference rows for the removed notificationLeave them (ignored automatically)Preferences for unknown notification IDs are silently skipped — no cleanup needed
Removing a notification is safe. Delete it from the array and remove all 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:

SymptomCauseFix
NOTIFYKIT_SECRET is required on startupMissing or empty .env.localRun 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 APIEnsure you're on localhost:3000 (not 3001). The provider points at port 3000 by default.
NotifyKitNotFoundError: Recipient not foundupsertRecipient() wasn't called before send()Always create the recipient first — see the "Send your first notification" example above
TypeScript error on notificationIdThe ID doesn't match any registered definitionCheck the exact id string in your notification definitions — it's case-sensitive
Module not found: @notifykitjs/corenpm install didn't complete or ran in wrong directoryRun npm install again from the project root (where package.json is)
Email "sent" but nothing in your inboxThe scaffold uses fakeEmailProvider() — it logs, not sendsExpected! Check terminal output for the logged email. Swap to a real provider for actual delivery.
Preferences page shows empty listNo notifications registered or provider not wrapping the appVerify <NotifyKitProvider> wraps your layout and notifications array isn't empty
Still stuck? Run 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:

1
Can you import notify?

Add console.log(notify.definitions.length) to a server file. If it prints a number, the instance is configured correctly.

2
Does the API route respond?

Visit /api/notifykit/notifications in your browser. You should see a JSON object with notification metadata in its data array.

3
Does the provider wrap the page?

Open React DevTools → search for NotifyKitProvider. If it's not in the tree, hooks will return empty state.

4
Does send() return a result?

Log the full result object. Check result.inboxItems (should have items) and result.skipped (should explain any missing channels).

What to try next

GoalPageWhat you'll learn
Add a second notification typeDefiningPayload schemas, categories, optional fields
Let users opt out of emailPreferencesPer-channel toggles, unsubscribe links
Batch noisy eventsDigestsWindow-based coalescing with render()
Show a live notification bellReact hooksuseInbox(), realtime updates
Understand why something was skippedExplainDry-run a send and inspect the resolution trail