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.

1
Route handler

Required. Exposes the REST API that the React SDK calls.

2
Provider

Required. Wraps your app so hooks know where the API lives.

3
Server actions

Optional. Skip REST and call NotifyKit directly from server components.

4
Middleware

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/react

Route handler

Create a catch-all route that exposes the NotifyKit REST API for the React client:

app/api/notifykit/[...route]/route.ts
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 shapeReturn valueWhat 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
UnauthenticatednullRequest rejected with 401 — no data exposed
Only 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:

LibrarySession sourceUser ID field
NextAuth / Auth.jsauth() or getServerSession()session.user.id
Clerkauth() from @clerk/nextjs/serveruserId (direct)
Supabase AuthcreateRouteHandlerClientuser.id from getUser()
Lucialucia.readSessionCookie + validateSessionsession.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 }
  },
})
Match the ID you upsert recipients with. The value you return as 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

Route handler vs server actions? Use the route handler when your UI uses React hooks (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:

lib/notifykit-actions.ts
import { createServerActions } from "@notifykitjs/next"
import { notify } from "@/lib/notifykit"
import { getSessionUserId } from "@/lib/session"

export const notifyActions = createServerActions({
  notifykit: notify,
  identify: () => getSessionUserId(),
})
app/settings/notifications/page.tsx
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):

middleware.ts
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:

app/layout.tsx
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.ts
app/(app)/layout.tsx
import { 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>
  )
}
PatternWhen to useTrade-off
Root layoutEvery page requires auth (dashboards, internal tools)Simple, but unauthenticated pages trigger 401s from the SSE connection
Route groupsMix of public and authenticated pagesSlightly more files, but no wasted connections on public pages
Conditional renderSingle layout, but some users are logged outProvider 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:

app/layout.tsx
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>
  )
}
Don't render the provider without a session. The inbox hooks immediately load user data, and hooks connect to /inbox/stream when realtime is enabled. Without a valid session, those requests return 401 and add avoidable noise.
Route groups are the cleanest pattern. They avoid conditional logic in layouts, prevent SSE connections on public pages, and make the auth boundary explicit in your file tree. Start here unless you have a reason not to.

Full file tree

FileRequiredWhat it does
lib/notifykit.tsYesCreates the NotifyKit instance + notification definitions
app/api/notifykit/[...route]/route.tsYesREST handler — inbox, preferences, unsubscribe, realtime
app/layout.tsxYesWraps app in <NotifyKitProvider> so hooks work
lib/session.tsYesYour auth helper — returns the current user ID
lib/notifykit-actions.tsNoServer actions for reading/writing without REST
middleware.tsNoCORS support for cross-origin clients
Minimum viable setup = 3 files. The NotifyKit instance, the route handler, and the provider in layout. Server actions and middleware are optional add-ons for specific use cases.

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.

MethodPathAuthPurpose
GET/inboxRequiredList inbox items for the authenticated user
POST/inbox/:id/readRequiredMark a single item as read
POST/inbox/mark-all-readRequiredMark all items as read
POST/inbox/:id/archiveRequiredArchive an item
POST/inbox/:id/unarchiveRequiredUnarchive an item
DELETE/inbox/:idRequiredPermanently delete an item
GET/inbox/unread-countRequiredGet unread count (for badges)
GET/preferencesRequiredList all preferences for the user
POST/preferencesRequiredUpdate channel preference for a notification
GET/notificationsOptionalList registered notification metadata (for building UIs)
GET/inbox/streamRequiredSSE stream — realtime inbox events
GET/unsubscribeTokenOne-click email unsubscribe (HMAC-verified)

Query parameters

RouteParameterDefaultExample
GET /inboxarchivedfalse/inbox?archived=true — list archived items
GET /inboxlimit50/inbox?limit=20 — cap returned items
POST /preferencesBody: notificationId, channels{"notificationId":"x","channels":{"email":false}}
Debug with curl. All routes use cookie-based auth (same as your app). To test from a terminal, grab a session cookie from your browser and pass it: curl -b "session=..." http://localhost:3000/api/notifykit/inbox

Integration 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 testAssertCatches
Inbox read/writeItems return after send, markRead sets readAtBroken handler wiring, schema mismatches
Preferences round-tripUpdate persists and affects next sendScope leaks, tenant isolation bugs
Auth rejectionidentify() → null returns 401Auth bypass, missing null check
Multi-tenant isolationOrg A handler can't see Org B itemsCross-tenant data leaks
No HTTP server needed. The handler is a standard (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:

PlatformSSE / realtimeFlush strategyKey constraint
Vercel FunctionsStreaming works until the invocation's maximum durationExternal cron (Vercel Cron or upstream)Instances are ephemeral; use shared storage and expect client reconnects
Self-hosted (Node)Full SSE with no timeoutsetInterval in-processManage your own process lifecycle, TLS termination, and scaling
Docker / Railway / FlyFull SSE (long-lived containers)setInterval or platform cronSet proxy idle timeouts above NotifyKit's 30-second SSE heartbeat
AWS Lambda (via SST/OpenNext)Streaming lifetime is bounded by the function timeoutEventBridge Scheduler or SQSUse 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:

VariablePurposeWhere to get it
NOTIFYKIT_SECRETSigns unsubscribe tokens (HMAC-SHA256)Generate: openssl rand -hex 32
DATABASE_URLPostgres connection string for the Drizzle adapterYour database provider (Neon, Supabase, RDS, etc.)
RESEND_API_KEYEmail delivery (or your provider's equivalent)Provider dashboard → API Keys
RESEND_FROMSender address: App <noreply@yourapp.com>Must match a verified domain in your provider
NOTIFYKIT_SECRET must be stable. Rotating it invalidates all outstanding unsubscribe links in sent emails. If you must rotate, keep the old secret in a NOTIFYKIT_SECRET_PREVIOUS env var and configure both — the handler tries both when verifying tokens.

Pre-deploy checklist

1
Swap adapters

memoryAdapter()drizzlePostgresAdapter(db). Run migrations.

2
Swap providers

fakeEmailProvider()resendProvider(). Test with the smoke test script.

3
Wire real auth

Replace any hardcoded identify() with your production session resolver.

4
Set baseUrl

Update unsubscribe.baseUrl to your production URL (e.g. https://app.com/api/notifykit).

5
Verify end-to-end

Send a test notification to yourself. Confirm: inbox item appears, email arrives, unsubscribe link works.

Deploy without email first. Ship with 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.

app/api/cron/notifykit/route.ts
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(),
  })
}
vercel.json
{
  "crons": [
    {
      "path": "/api/cron/notifykit",
      "schedule": "* * * * *"
    }
  ]
}
MethodWhat it flushesWhen it matters
flushScheduledSends()Sends deferred by quiet hoursUser has quiet hours 10pm–8am — email queued at 11pm delivers at 8am
flushDigests()Digest buckets past their windowUser gets a single "5 new comments" email instead of 5 separate ones
Protect the cron endpoint. Without the 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.
1-minute schedule is fine for most apps. Cron fires every minute but only processes items whose window has expired. If nothing is pending, the call returns immediately with zero results. The cost is one cold start per minute, not one email per minute.

Troubleshooting

SymptomCauseFix
401 on every client requestidentify() returns undefined instead of nullReturn null explicitly when no session. undefined is treated as a broken handler, not "unauthenticated."
Hooks return empty dataMissing <NotifyKitProvider> or wrong baseUrlVerify the provider wraps your component tree and baseUrl matches the route (e.g. /api/notifykit).
SSE stream disconnects immediatelyRoute cached by Next.js static optimizationThe dynamic export from createRouteHandler() handles this — make sure you export it.
CORS errors from a separate frontendNo CORS headers on the NotifyKit routesAdd cors option to the handler or use the middleware helper. OPTIONS must also be exported.
Preferences update but revert on refreshTenant/workspace mismatch between reads and writesEnsure identify() returns the same scope for all requests from the same session.