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 respected
No platform required. No hosted dashboard, no external workflow editor, no third-party queue. NotifyKit runs in-process inside your app. You own the infrastructure and the data.

How it works

1
Define

Declare notifications with typed payloads and channel configs. They live in your codebase, version-controlled and type-checked.

2
Send

Call notify.send() from anywhere on the server. The engine resolves preferences, applies rate limits and quiet hours, then renders templates.

3
Deliver

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.

BoundaryWhat crosses itWho controls it
Server → Enginesend() calls with typed payloadsYour application code
Engine → DatabaseInbox items, preferences, delivery recordsDatabase adapter (Drizzle, custom)
Engine → ProvidersRendered email/SMS/webhook payloadsProvider adapter (Resend, Postmark, custom)
Handler → BrowserREST API (inbox, preferences) + SSE eventsRoute handler + identify()
Browser → ReactHooks consume the REST API automatically<NotifyKitProvider> + hooks
The default path stays in your process. The engine is a function call, and 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:

StageWhat it doesDocs
1. ValidateCheck payload against schemaDefining
2. IdempotencyReplay if key already seenDedup & idempotency
3. DedupSkip if same event within windowDedup & idempotency
4. Rate limitDrop if over thresholdDigests & rate limits
5. DigestBuffer into a batch windowDigests & rate limits
6. PreferencesSkip channels user opted out ofPreferences
7. Quiet hoursDefer push channels until window endsQuiet hours
8. DeliverQueue to provider (email, SMS, webhook) + write inboxChannels
9. RetryRetry failed deliveries with backoffProviders
10. FallbackFire alternate channel if primary failsFallbacks
Order matters for debugging. If a send returns 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:

NotifyKitManaged platformsSelf-hosted platform
Runs whereYour app or workerVendor infrastructureA separate stack you operate
StateYour application databaseVendor-managedPlatform database you operate
Source of truthImported TypeScript definitionsDashboard, API, or synced filesDashboard or code-first workflows
Visual editingNoUsually includedUsually included
Delivery operationsYou provide durable queueing and monitoringManaged for youYou operate the platform workers
Best fitTypeScript teams wanting app-owned stateTeams wanting managed workflows and channelsTeams wanting a full platform on their infrastructure
Best fit: teams that already manage their own database and want notifications as a library rather than a service dependency. If you prefer managed reliability, many turnkey channels, or a visual editor for non-engineers, a platform is the better choice.

Package architecture

NotifyKit is split into focused packages. Only install what you use — here's how they relate:

LayerPackageDepends onInstall when
Engine@notifykitjs/coreAlways (the only required package)
Persistence@notifykitjs/drizzlecoreYou need data to survive restarts
Framework@notifykitjs/nextcoreExposing REST/SSE routes in Next.js
UI@notifykitjs/reactBuilding inbox/preferences UI in React
Provider@notifykitjs/resendcoreSending real emails via Resend
Realtime@notifykitjs/realtime-pgcoreMulti-instance deploys with Postgres pub/sub
Testing@notifykitjs/testingcoreAsserting on sends in test suites
Typical installs by stage:
Prototype: core (memory adapter, fake provider — zero deps)
Dev with UI: core + next + react
Production: core + next + react + drizzle + resend

Common 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 typeChannelsDatabaseKey features
SaaS with inboxInbox + EmailPostgres (Drizzle)Preferences, realtime SSE, unsubscribe links, multi-tenancy
Internal toolInbox + Webhook (Slack)SQLiteSimple setup, no email provider, webhook to Slack channel
Transactional onlyEmail + SMSPostgresrequired: true, no inbox UI, delivery tracking + timeline
High-volume socialInbox + Email (digested)PostgresDigests, rate limits, dedup, BullMQ queue for background delivery
Prototype / MVPInbox onlyMemoryZero deps, no email provider, upgrades later with no code changes
Start with the MVP row and move down. Every row adds complexity on top of the one above it. You can always add email, swap the database, or enable digests later — notification definitions and client code stay the same.

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:

Scenario: User "Rey" comments on a post at 11:02 PM. The recipient has quiet hours (10 PM – 8 AM), email digests (5 min window), and has opted out of SMS.
StageWhat happensResult field
Stage 1 — ValidatePayload passes — actorName and postUrl are present
Stage 3 — DedupKey mention:post_42:rey not seen → passes
Stage 4 — Rate limitUnder threshold (3 of 20/hour used) → passes
Stage 5 — DigestEmail has a 5-min digest window → buffered, no email yetdigested: true
Stage 6 — PreferencesSMS disabled by user → skipped. Inbox + email allowed.skipped: [{channel: "sms", reason: "preferences_disabled"}]
Stage 7 — Quiet hours11:02 PM is inside the window → email deferred to 8 AMdeferredChannels: ["email"]
Stage 8 — DeliverInbox is a pull channel — writes immediately regardless of quiet hoursinboxItems: [...]

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.

1
11:02 PM — Send enters pipeline

Inbox item written. Email buffered into digest. SMS skipped (preference).

2
11:04, 11:06 PM — More sends arrive

Same dedup key? Dropped. Different actors? Append to digest buffer.

3
11:07 PM — Digest window expires

render() combines 3 payloads into one. Still in quiet hours → deferred.

4
8:00 AM — Quiet hours end

flushScheduledSends() fires. One combined email delivered via provider.

Feature interactionBehaviorDocs
Digest + quiet hoursDigest flushes, then quiet hours defers the flushed sendDigests, Quiet hours
Dedup + digestDedup runs first — a duplicate never enters the digest bufferDedup
Preferences + fallbackIf user disables email but has a fallback to inbox, fallback firesPreferences, Fallbacks
Rate limit + digestRate limit runs before digest — a rate-limited send never enters the bufferDigests
Quiet hours + inboxInbox always writes immediately — only push channels deferQuiet hours
Use explain() to trace interactions. When features compose in unexpected ways, 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 reportsMost likely causeCheckDocs
"I never got the email"Opted out via preferences, or quiet hours deferred itexplain().channels.email.outcomeExplain
"Email arrived hours late"Quiet hours held the delivery until the window endedresult.deferredChannels or timeline quiet_hours.deferredQuiet hours
"I got the same notification twice"Missing idempotencyKey on a retryable triggerAdd a unique key per logical eventDedup
"Nothing shows in my inbox"upsertRecipient() not called, or wrong recipientIdtimeline() — look for recipient.resolvedTimeline
"Notifications stopped entirely"Rate limit reached, or provider key expiredresult.rateLimited or delivery.failed hookHooks
"Inbox updates but no email"Recipient has no email field, or email channel disabledresult.skipped — look for missing_destinationChannels
Rule of thumb: run 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.

Digests & rate limits Dedup Quiet hours Preferences

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 arriveExplain & dry run
See what happened to a past sendTimeline
Look up the API for a specific methodAPI reference
Understand the TypeScript typesTypes
See all handler routes and their shapesHandler routes