Next.js integration
The @notifykitjs/next package provides a route handler, server actions, and optional middleware for CORS. It works with Next.js 14+ App Router.
Required. Exposes the REST API that the React SDK calls.
Required. Wraps your app so hooks know where the API lives.
Optional. Skip REST and call NotifyKit directly from server components.
Optional. Only needed for cross-origin clients (mobile webviews, separate frontends).
Zero-config route handler
One catch-all route exposes inbox, preferences, unsubscribe, and SSE. No manual endpoint wiring.
Any auth library
Works with NextAuth, Clerk, Supabase, Lucia — just return the user ID from your session helper.
Server actions support
Read and write notifications from server components and form actions without a client-side SDK.
App Router native
Built for Next.js 14+ App Router. Route groups, layouts, and streaming all work out of the box.
Install
npm install @notifykitjs/core @notifykitjs/next @notifykitjs/reactRoute handler
Create a catch-all route that exposes the NotifyKit REST API for the React client:
import { createRouteHandler } from "@notifykitjs/next"
import { notify } from "@/lib/notifykit"
import { auth } from "@/lib/auth"
export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler({
notifykit: notify,
identify: async (request) => {
const session = await auth(request)
if (!session) return null // → 401
return {
recipientId: session.user.id,
tenantId: session.organizationId, // optional
workspaceId: session.workspaceId, // optional
}
},
unsubscribeSecret: process.env.NOTIFYKIT_SECRET,
})The identify function resolves the current user from the request. Return null to reject unauthenticated requests with a 401.
What identify() should return
| App shape | Return value | What it unlocks |
|---|---|---|
| Single-user app | { recipientId: userId } | Inbox + preferences scoped to the user |
| Multi-tenant SaaS | { recipientId, tenantId } | + tenant isolation — users can't cross orgs |
| Workspace-per-project | { recipientId, tenantId, workspaceId } | + workspace-level preference overrides |
| Unauthenticated | null | Request rejected with 401 — no data exposed |
recipientId is required. Add tenantId or workspaceId when you need scoping. Extra fields you return are available in hooks and authorize() but don't affect routing.Auth library examples
The identify() function is where your auth library meets NotifyKit. Here's the exact wiring for the most common Next.js auth solutions:
| Library | Session source | User ID field |
|---|---|---|
| NextAuth / Auth.js | auth() or getServerSession() | session.user.id |
| Clerk | auth() from @clerk/nextjs/server | userId (direct) |
| Supabase Auth | createRouteHandlerClient | user.id from getUser() |
| Lucia | lucia.readSessionCookie + validateSession | session.userId |
NextAuth / Auth.js
import { auth } from "@/auth"
export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler({
notifykit: notify,
identify: async () => {
const session = await auth()
if (!session?.user?.id) return null
return { recipientId: session.user.id }
},
})Clerk
Clerk exposes orgId directly — pass it as tenantId to get multi-tenant isolation for free.
import { auth } from "@clerk/nextjs/server"
export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler({
notifykit: notify,
identify: async () => {
const { userId, orgId } = await auth()
if (!userId) return null
return { recipientId: userId, tenantId: orgId ?? undefined }
},
})Supabase Auth
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
import { cookies } from "next/headers"
export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler({
notifykit: notify,
identify: async () => {
const supabase = createRouteHandlerClient({ cookies })
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
return { recipientId: user.id }
},
})Lucia
import { lucia } from "@/lib/lucia"
import { cookies } from "next/headers"
export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler({
notifykit: notify,
identify: async () => {
const cookieStore = await cookies()
const sessionId = lucia.readSessionCookie(cookieStore.toString())
if (!sessionId) return null
const { session } = await lucia.validateSession(sessionId)
if (!session) return null
return { recipientId: session.userId }
},
})recipientId in identify() must be the same string you pass to upsertRecipient({ id })when creating recipients. If your auth library uses user_abc123but you upsert with a database UUID, the inbox will be empty.Server actions
useInbox, usePreferences). Use server actions when you want to read/write from server components or form actions without a client SDK. Both use the same identify() pattern.For tighter integration without REST calls, use server actions directly:
import { createServerActions } from "@notifykitjs/next"
import { notify } from "@/lib/notifykit"
import { getSessionUserId } from "@/lib/session"
export const notifyActions = createServerActions({
notifykit: notify,
identify: () => getSessionUserId(),
})import { notifyActions } from "@/lib/notifykit-actions"
export default async function NotificationSettings() {
const preferences = await notifyActions.getPreferences()
async function toggleEmail(formData: FormData) {
"use server"
await notifyActions.updatePreference({
notificationId: formData.get("notificationId") as string,
channels: { email: formData.get("enabled") === "true" },
})
}
return (
<form action={toggleEmail}>
{/* render preferences with form inputs */}
</form>
)
}Middleware (CORS)
When your client is on a different origin (e.g. a mobile web view hitting your API):
import { createNotifyKitMiddleware } from "@notifykitjs/next/middleware"
import type { NextRequest } from "next/server"
const withNotifyKit = createNotifyKitMiddleware({
cors: { origin: "https://app.example.com" },
})
export function middleware(request: NextRequest) {
return withNotifyKit(request)
}
export const config = { matcher: "/api/notifykit/:path*" }Provider pattern
Wrap your app in NotifyKitProvider to make hooks work. Point it at the route handler. The provider must only render for authenticated users — it opens an SSE connection that requires a valid session.
Basic: single layout
For apps where every page requires auth, wrap at the root:
import { NotifyKitProvider } from "@notifykitjs/react"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<NotifyKitProvider options={{ baseUrl: "/api/notifykit" }}>
{children}
</NotifyKitProvider>
</body>
</html>
)
}Recommended: route groups
Most Next.js apps have public pages (landing, login, docs) that don't need notifications. Use route groups to scope the provider to authenticated routes only:
app/
├── (public)/ ← No provider, no SSE connection
│ ├── layout.tsx ← Plain layout (no NotifyKitProvider)
│ ├── page.tsx ← Landing page
│ └── login/page.tsx ← Login page
├── (app)/ ← Provider wraps this group
│ ├── layout.tsx ← Has NotifyKitProvider
│ ├── dashboard/page.tsx
│ └── settings/page.tsx
└── api/
└── notifykit/[...route]/route.tsimport { NotifyKitProvider } from "@notifykitjs/react"
import { redirect } from "next/navigation"
import { auth } from "@/lib/auth"
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
if (!session) redirect("/login")
return (
<NotifyKitProvider options={{ baseUrl: "/api/notifykit" }}>
{children}
</NotifyKitProvider>
)
}| Pattern | When to use | Trade-off |
|---|---|---|
| Root layout | Every page requires auth (dashboards, internal tools) | Simple, but unauthenticated pages trigger 401s from the SSE connection |
| Route groups | Mix of public and authenticated pages | Slightly more files, but no wasted connections on public pages |
| Conditional render | Single layout, but some users are logged out | Provider mounts/unmounts on auth state change — hooks reset on login |
Conditional render (alternative)
If route groups don't fit your structure, conditionally render the provider based on session state:
import { NotifyKitProvider } from "@notifykitjs/react"
import { auth } from "@/lib/auth"
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
return (
<html>
<body>
{session ? (
<NotifyKitProvider options={{ baseUrl: "/api/notifykit" }}>
{children}
</NotifyKitProvider>
) : (
children
)}
</body>
</html>
)
}/inbox/stream when realtime is enabled. Without a valid session, those requests return 401 and add avoidable noise.Full file tree
| File | Required | What it does |
|---|---|---|
lib/notifykit.ts | Yes | Creates the NotifyKit instance + notification definitions |
app/api/notifykit/[...route]/route.ts | Yes | REST handler — inbox, preferences, unsubscribe, realtime |
app/layout.tsx | Yes | Wraps app in <NotifyKitProvider> so hooks work |
lib/session.ts | Yes | Your auth helper — returns the current user ID |
lib/notifykit-actions.ts | No | Server actions for reading/writing without REST |
middleware.ts | No | CORS support for cross-origin clients |
Route reference
The handler exposes these REST endpoints. The React SDK calls them automatically — this reference is for custom clients, mobile apps, or debugging with curl.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /inbox | Required | List inbox items for the authenticated user |
POST | /inbox/:id/read | Required | Mark a single item as read |
POST | /inbox/mark-all-read | Required | Mark all items as read |
POST | /inbox/:id/archive | Required | Archive an item |
POST | /inbox/:id/unarchive | Required | Unarchive an item |
DELETE | /inbox/:id | Required | Permanently delete an item |
GET | /inbox/unread-count | Required | Get unread count (for badges) |
GET | /preferences | Required | List all preferences for the user |
POST | /preferences | Required | Update channel preference for a notification |
GET | /notifications | Optional | List registered notification metadata (for building UIs) |
GET | /inbox/stream | Required | SSE stream — realtime inbox events |
GET | /unsubscribe | Token | One-click email unsubscribe (HMAC-verified) |
Query parameters
| Route | Parameter | Default | Example |
|---|---|---|---|
GET /inbox | archived | false | /inbox?archived=true — list archived items |
GET /inbox | limit | 50 | /inbox?limit=20 — cap returned items |
POST /preferences | Body: notificationId, channels | — | {"notificationId":"x","channels":{"email":false}} |
curl -b "session=..." http://localhost:3000/api/notifykit/inboxIntegration testing
Test the full stack — handler, identify, hooks — without deploying. Create a test NotifyKit instance with memory adapter and hit the routes directly:
import { describe, it, expect, beforeAll } from "vitest"
import { createNotifyKit, memoryAdapter, fakeEmailProvider } from "@notifykitjs/core"
import { createHandler } from "@notifykitjs/core"
import { commentMentioned } from "@/lib/notifications/comment-mentioned"
const testNotify = createNotifyKit({
notifications: [commentMentioned] as const,
database: memoryAdapter(),
providers: { email: fakeEmailProvider() },
})
const handler = createHandler(testNotify, {
identify: async () => ({ recipientId: "test_user" }),
basePath: "/",
})
describe("NotifyKit handler", () => {
beforeAll(async () => {
await testNotify.upsertRecipient({ id: "test_user", email: "test@example.com" })
await testNotify.send({
recipientId: "test_user",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postTitle: "Test", postUrl: "/posts/1" },
})
})
it("GET /inbox returns items", async () => {
const req = new Request("http://localhost/inbox")
const res = await handler(req)
expect(res.status).toBe(200)
const { data: items } = await res.json()
expect(items.length).toBeGreaterThan(0)
expect(items[0].title).toContain("Rey")
})
it("POST /inbox/:id/read marks as read", async () => {
const listRes = await handler(new Request("http://localhost/inbox"))
const { data: items } = await listRes.json()
const [item] = items
const req = new Request(`http://localhost/inbox/${item.id}/read`, { method: "POST" })
const res = await handler(req)
expect(res.status).toBe(200)
const { data: updated } = await res.json()
expect(updated.readAt).not.toBeNull()
})
it("POST /preferences updates channel opt-out", async () => {
const req = new Request("http://localhost/preferences", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notificationId: "comment_mentioned", channels: { email: false } }),
})
const res = await handler(req)
expect(res.status).toBe(200)
const { data: pref } = await res.json()
expect(pref.channels.email).toBe(false)
})
it("returns 401 when identify returns null", async () => {
const noAuthHandler = createHandler(testNotify, {
identify: async () => null,
basePath: "/",
})
const req = new Request("http://localhost/inbox")
const res = await noAuthHandler(req)
expect(res.status).toBe(401)
})
})| What to test | Assert | Catches |
|---|---|---|
| Inbox read/write | Items return after send, markRead sets readAt | Broken handler wiring, schema mismatches |
| Preferences round-trip | Update persists and affects next send | Scope leaks, tenant isolation bugs |
| Auth rejection | identify() → null returns 401 | Auth bypass, missing null check |
| Multi-tenant isolation | Org A handler can't see Org B items | Cross-tenant data leaks |
(Request) → Response function. Call it directly in tests with new Request() — no supertest, no server boot, sub-millisecond execution.Deploying
Your local setup uses memory adapters and fake providers. Production needs real credentials and platform-specific config. Here's what changes per hosting platform:
| Platform | SSE / realtime | Flush strategy | Key constraint |
|---|---|---|---|
| Vercel Functions | Streaming works until the invocation's maximum duration | External cron (Vercel Cron or upstream) | Instances are ephemeral; use shared storage and expect client reconnects |
| Self-hosted (Node) | Full SSE with no timeout | setInterval in-process | Manage your own process lifecycle, TLS termination, and scaling |
| Docker / Railway / Fly | Full SSE (long-lived containers) | setInterval or platform cron | Set proxy idle timeouts above NotifyKit's 30-second SSE heartbeat |
| AWS Lambda (via SST/OpenNext) | Streaming lifetime is bounded by the function timeout | EventBridge Scheduler or SQS | Use polling or an external WebSocket service for realtime |
Environment variables
Set these in your platform's environment configuration. All are required for a working production deploy:
| Variable | Purpose | Where to get it |
|---|---|---|
NOTIFYKIT_SECRET | Signs unsubscribe tokens (HMAC-SHA256) | Generate: openssl rand -hex 32 |
DATABASE_URL | Postgres connection string for the Drizzle adapter | Your database provider (Neon, Supabase, RDS, etc.) |
RESEND_API_KEY | Email delivery (or your provider's equivalent) | Provider dashboard → API Keys |
RESEND_FROM | Sender address: App <noreply@yourapp.com> | Must match a verified domain in your provider |
NOTIFYKIT_SECRET_PREVIOUS env var and configure both — the handler tries both when verifying tokens.Pre-deploy checklist
memoryAdapter() → drizzlePostgresAdapter(db). Run migrations.
fakeEmailProvider() → resendProvider(). Test with the smoke test script.
Replace any hardcoded identify() with your production session resolver.
Update unsubscribe.baseUrl to your production URL (e.g. https://app.com/api/notifykit).
Send a test notification to yourself. Confirm: inbox item appears, email arrives, unsubscribe link works.
fakeEmailProvider() and only inbox channels enabled. Verify the route handler, auth, and database work in production. Then swap in the real email provider in a follow-up deploy — this isolates failures.Cron routes for serverless
Serverless platforms (Vercel, Lambda) have no persistent process to run timers. You need cron-triggered routes that call flushScheduledSends() and flushDigests() on a schedule. Without these, quiet-hours sends and digests never fire.
import { notify } from "@/lib/notifykit"
import { NextResponse } from "next/server"
// Vercel Cron calls this route on a schedule.
// Protect with CRON_SECRET to prevent public access.
export async function GET(request: Request) {
const authHeader = request.headers.get("authorization")
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 })
}
const [scheduledResults, digestResults] = await Promise.all([
notify.flushScheduledSends(),
notify.flushDigests(),
])
return NextResponse.json({
scheduled: scheduledResults.length,
digests: digestResults.length,
flushedAt: new Date().toISOString(),
})
}{
"crons": [
{
"path": "/api/cron/notifykit",
"schedule": "* * * * *"
}
]
}| Method | What it flushes | When it matters |
|---|---|---|
flushScheduledSends() | Sends deferred by quiet hours | User has quiet hours 10pm–8am — email queued at 11pm delivers at 8am |
flushDigests() | Digest buckets past their window | User gets a single "5 new comments" email instead of 5 separate ones |
CRON_SECRET check, anyone can trigger flushes by hitting the URL. Vercel automatically sends the Authorization header from your environment — set CRON_SECRET in your Vercel project settings to match.Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 401 on every client request | identify() returns undefined instead of null | Return null explicitly when no session. undefined is treated as a broken handler, not "unauthenticated." |
| Hooks return empty data | Missing <NotifyKitProvider> or wrong baseUrl | Verify the provider wraps your component tree and baseUrl matches the route (e.g. /api/notifykit). |
| SSE stream disconnects immediately | Route cached by Next.js static optimization | The dynamic export from createRouteHandler() handles this — make sure you export it. |
| CORS errors from a separate frontend | No CORS headers on the NotifyKit routes | Add cors option to the handler or use the middleware helper. OPTIONS must also be exported. |
| Preferences update but revert on refresh | Tenant/workspace mismatch between reads and writes | Ensure identify() returns the same scope for all requests from the same session. |