Overview
NotifyKit is a TypeScript framework for building notifications directly inside your app. Define notifications as code, store state in your own database, and deliver across inbox, email, SMS, and webhook channels.
import { notify } from "@/lib/notifykit"
await notify.send({
recipientId: user.id,
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/posts/42" },
})
// → inbox item created, email queued, preferences respectedHow it works
Declare notifications with typed payloads and channel configs. They live in your codebase, version-controlled and type-checked.
Call notify.send() from anywhere on the server. The engine resolves preferences, applies rate limits and quiet hours, then renders templates.
Each channel fires independently — inbox writes to your DB, email goes through your provider, webhooks POST to your endpoints. All with retry and fallback built in.
Key ideas
Notifications as code
Typed definitions live in your codebase. Refactor, review, and deploy like any other code.
Your database
Inbox, preferences, and delivery logs write to your existing DB via adapters. No external state.
Multi-channel
One send resolves to inbox, email, SMS, or webhook — based on config and user preferences.
Full pipeline
Rate limits, quiet hours, digests, dedup, retries, and fallbacks — all built in.
Architecture
NotifyKit runs inside your application — not as a separate service. Here's where each piece lives in a typical deployment:
Your server code
Server actions, API routes, background jobs. Calls notify.send() and upsertRecipient().
NotifyKit engine
Runs in-process. Resolves preferences, applies rate limits, renders templates, queues deliveries.
Your database
Inbox items, preferences, delivery records, and timeline — all in tables you own.
Providers
Resend and SMTP integrations are included. Email, SMS, and webhook provider contracts let you add your own.
| Boundary | What crosses it | Who controls it |
|---|---|---|
| Server → Engine | send() calls with typed payloads | Your application code |
| Engine → Database | Inbox items, preferences, delivery records | Database adapter (Drizzle, custom) |
| Engine → Providers | Rendered email/SMS/webhook payloads | Provider adapter (Resend, Postmark, custom) |
| Handler → Browser | REST API (inbox, preferences) + SSE events | Route handler + identify() |
| Browser → React | Hooks consume the REST API automatically | <NotifyKitProvider> + hooks |
inlineQueue() runs the pipeline before returning. For crash-safe asynchronous delivery, persist jobs through a durable queue and process them with processDeliveryJob().The send pipeline
Every call to send() passes through these stages. Each is optional — skip what you don't need — but they're always evaluated in this order:
| Stage | What it does | Docs |
|---|---|---|
| 1. Validate | Check payload against schema | Defining |
| 2. Idempotency | Replay if key already seen | Dedup & idempotency |
| 3. Dedup | Skip if same event within window | Dedup & idempotency |
| 4. Rate limit | Drop if over threshold | Digests & rate limits |
| 5. Digest | Buffer into a batch window | Digests & rate limits |
| 6. Preferences | Skip channels user opted out of | Preferences |
| 7. Quiet hours | Defer push channels until window ends | Quiet hours |
| 8. Deliver | Queue to provider (email, SMS, webhook) + write inbox | Channels |
| 9. Retry | Retry failed deliveries with backoff | Providers |
| 10. Fallback | Fire alternate channel if primary fails | Fallbacks |
rateLimited: true, it never reached preferences or delivery. Use explain() to see exactly where the pipeline stopped.How NotifyKit compares
NotifyKit is an embedded framework, not a managed notification platform. The categories solve overlapping problems but make different operational trade-offs:
| NotifyKit | Managed platforms | Self-hosted platform | |
|---|---|---|---|
| Runs where | Your app or worker | Vendor infrastructure | A separate stack you operate |
| State | Your application database | Vendor-managed | Platform database you operate |
| Source of truth | Imported TypeScript definitions | Dashboard, API, or synced files | Dashboard or code-first workflows |
| Visual editing | No | Usually included | Usually included |
| Delivery operations | You provide durable queueing and monitoring | Managed for you | You operate the platform workers |
| Best fit | TypeScript teams wanting app-owned state | Teams wanting managed workflows and channels | Teams wanting a full platform on their infrastructure |
Package architecture
NotifyKit is split into focused packages. Only install what you use — here's how they relate:
| Layer | Package | Depends on | Install when |
|---|---|---|---|
| Engine | @notifykitjs/core | — | Always (the only required package) |
| Persistence | @notifykitjs/drizzle | core | You need data to survive restarts |
| Framework | @notifykitjs/next | core | Exposing REST/SSE routes in Next.js |
| UI | @notifykitjs/react | — | Building inbox/preferences UI in React |
| Provider | @notifykitjs/resend | core | Sending real emails via Resend |
| Realtime | @notifykitjs/realtime-pg | core | Multi-instance deploys with Postgres pub/sub |
| Testing | @notifykitjs/testing | core | Asserting on sends in test suites |
Prototype:
core (memory adapter, fake provider — zero deps)Dev with UI:
core + next + reactProduction:
core + next + react + drizzle + resendCommon setups
NotifyKit adapts to different app types. Answer these questions to find your starting point:
Do users need to see notifications in the app?
Yes → you need an inbox channel. No → transactional-only (email/SMS).
Will you send more than 10 notifications/user/hour?
Yes → enable digests and rate limits. No → direct delivery is fine.
Do you have multiple organizations/tenants?
Yes → add tenantId scoping. No → single-tenant is simpler.
Match your answers to the table below:
| App type | Channels | Database | Key features |
|---|---|---|---|
| SaaS with inbox | Inbox + Email | Postgres (Drizzle) | Preferences, realtime SSE, unsubscribe links, multi-tenancy |
| Internal tool | Inbox + Webhook (Slack) | SQLite | Simple setup, no email provider, webhook to Slack channel |
| Transactional only | Email + SMS | Postgres | required: true, no inbox UI, delivery tracking + timeline |
| High-volume social | Inbox + Email (digested) | Postgres | Digests, rate limits, dedup, BullMQ queue for background delivery |
| Prototype / MVP | Inbox only | Memory | Zero deps, no email provider, upgrades later with no code changes |
Feature interaction walkthrough
Pipeline stages don't operate in isolation — they compose. Here's what happens when multiple features are active on a single send:
| Stage | What happens | Result field |
|---|---|---|
| Stage 1 — Validate | Payload passes — actorName and postUrl are present | — |
| Stage 3 — Dedup | Key mention:post_42:rey not seen → passes | — |
| Stage 4 — Rate limit | Under threshold (3 of 20/hour used) → passes | — |
| Stage 5 — Digest | Email has a 5-min digest window → buffered, no email yet | digested: true |
| Stage 6 — Preferences | SMS disabled by user → skipped. Inbox + email allowed. | skipped: [{channel: "sms", reason: "preferences_disabled"}] |
| Stage 7 — Quiet hours | 11:02 PM is inside the window → email deferred to 8 AM | deferredChannels: ["email"] |
| Stage 8 — Deliver | Inbox is a pull channel — writes immediately regardless of quiet hours | inboxItems: [...] |
At 11:07 PM, the digest window expires. But quiet hours are still active, so the flushed digest is deferred until 8 AM. At 8:00 AM, the scheduled send fires and the user receives one email: "3 new comments on Launch Plan" — the digest collapsed 3 mentions into one delivery.
Inbox item written. Email buffered into digest. SMS skipped (preference).
Same dedup key? Dropped. Different actors? Append to digest buffer.
render() combines 3 payloads into one. Still in quiet hours → deferred.
flushScheduledSends() fires. One combined email delivered via provider.
| Feature interaction | Behavior | Docs |
|---|---|---|
| Digest + quiet hours | Digest flushes, then quiet hours defers the flushed send | Digests, Quiet hours |
| Dedup + digest | Dedup runs first — a duplicate never enters the digest buffer | Dedup |
| Preferences + fallback | If user disables email but has a fallback to inbox, fallback fires | Preferences, Fallbacks |
| Rate limit + digest | Rate limit runs before digest — a rate-limited send never enters the buffer | Digests |
| Quiet hours + inbox | Inbox always writes immediately — only push channels defer | Quiet hours |
explain() shows exactly which stage intercepted the send and why. The timeline shows the same information after the fact for production debugging.Quick debugging reference
When a notification doesn't arrive, pick the tool that matches what you know. Each answers a different question:
Can you reproduce the scenario?
Yes → use explain() to dry-run the send with zero side effects. See which stage blocked it.
Do you have the notification record ID?
Yes → use timeline() to see every event that happened during that send.
Did your code just call send()?
Yes → inspect the SendResult fields: skipped, deferredChannels, rateLimited, digested.
| User reports | Most likely cause | Check | Docs |
|---|---|---|---|
| "I never got the email" | Opted out via preferences, or quiet hours deferred it | explain().channels.email.outcome | Explain |
| "Email arrived hours late" | Quiet hours held the delivery until the window ended | result.deferredChannels or timeline quiet_hours.deferred | Quiet hours |
| "I got the same notification twice" | Missing idempotencyKey on a retryable trigger | Add a unique key per logical event | Dedup |
| "Nothing shows in my inbox" | upsertRecipient() not called, or wrong recipientId | timeline() — look for recipient.resolved | Timeline |
| "Notifications stopped entirely" | Rate limit reached, or provider key expired | result.rateLimited or delivery.failed hook | Hooks |
| "Inbox updates but no email" | Recipient has no email field, or email channel disabled | result.skipped — look for missing_destination | Channels |
explain() first. It costs nothing (no records written, no emails sent) and shows the full pipeline resolution in one call. If the issue is intermittent and you can't reproduce, pull the timeline for the specific notification record.Learning paths
Pick the path that matches where you are. Each is a sequence — read them in order for a guided walkthrough of that area:
First time here
Get a working setup, send your first notification, see it in the UI.
Quickstart → Defining → Sending → React hooks
Adding to an existing app
Install, wire auth, connect your database and email provider.
Installation → Next.js → Database → Providers
Reducing noise
Stop flooding users — add digests, rate limits, dedup, and quiet hours.
Going to production
Start with the reliability boundary, then add observability, security, and tenant isolation.
Production readiness → Hooks → Security → Multi-tenancy → Realtime
| I want to… | Go directly to |
|---|---|
| Debug why a notification didn't arrive | Explain & dry run |
| See what happened to a past send | Timeline |
| Look up the API for a specific method | API reference |
| Understand the TypeScript types | Types |
| See all handler routes and their shapes | Handler routes |