Next.js
Route handlers, server actions, and middleware for the App Router.
Setup
@notifykitjs/next works with the App Router in Next.js 14 and later. A working setup is three files: the NotifyKit instance, one catch-all route, and a provider around the pages that show notifications. Server actions and the CORS middleware are optional.
npm install @notifykitjs/core @notifykitjs/next @notifykitjs/react| File | Needed | What it does |
|---|---|---|
lib/notifykit.ts | Yes | Your definitions and createNotifyKit() |
app/api/notifykit/[...route]/route.ts | Yes | The REST routes the React hooks call |
app/(app)/layout.tsx | Yes | <NotifyKitProvider>, so the hooks work |
lib/notifykit-actions.ts | No | Server actions, for server components and forms |
middleware.ts | No | CORS, for clients on another origin |
Route Handler
One catch-all route serves the inbox, preferences, unsubscribe, and realtime stream. It must live at the handler’s base path, /api/notifykit by default, and you export all five names it returns:
import { createRouteHandler } from "@notifykitjs/next"
import { notify } from "@/lib/notifykit"
import { getSession } from "@/lib/auth"
export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler({
notifykit: notify,
identify: async (request) => {
const session = await getSession(request)
if (!session) return null // 401
return { recipientId: session.user.id, tenantId: session.orgId }
},
unsubscribeSecret: process.env.NOTIFYKIT_SECRET,
})dynamic is "force-dynamic", which keeps Next.js from caching the routes. Every other option is passed straight to createHandler() in the core, so basePath, cors, requestRateLimit, authorize, and the rest work here too. See Handler routes for the full list and every route.
identify()
identify() is the only thing that ties a request to a user; the routes never trust a recipient id sent by the browser. It receives the Request and returns one of:
| Return | Result |
|---|---|
"user_123" | The recipient, with no tenant |
{ recipientId, tenantId?, workspaceId? } | The recipient, scoped to a tenant or workspace (organizationId is accepted as another name for tenantId) |
null or undefined | 401, no data returned |
An empty or malformed id is treated as unauthenticated too. If identify() throws, the request gets a 500.
Auth libraries
import { auth } from "@/auth"
identify: async () => {
const session = await auth()
return session?.user?.id ?? null
},import { auth } from "@clerk/nextjs/server"
identify: async () => {
const { userId, orgId } = await auth()
if (!userId) return null
return { recipientId: userId, tenantId: orgId ?? undefined }
},Clerk’s organization id makes a natural tenant.
import { headers } from "next/headers"
import { auth } from "@/lib/auth"
identify: async () => {
const session = await auth.api.getSession({ headers: await headers() })
return session?.user.id ?? null
},import { createClient } from "@/lib/supabase/server"
identify: async () => {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
return user?.id ?? null
},Provider
The React hooks need <NotifyKitProvider> above them, pointed at the route. Render it only for signed-in users: the hooks start fetching as soon as they mount, and without a session every request is a 401. A route group keeps it off public pages:
app/
├── (public)/ no provider
│ ├── page.tsx
│ └── login/page.tsx
├── (app)/
│ ├── layout.tsx NotifyKitProvider here
│ └── settings/page.tsx
└── api/notifykit/[...route]/route.tsimport { NotifyKitProvider } from "@notifykitjs/react"
import { redirect } from "next/navigation"
import { getSession } from "@/lib/auth"
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await getSession()
if (!session) redirect("/login")
return (
<NotifyKitProvider options={{ baseUrl: "/api/notifykit", realtime: true }}>
{children}
</NotifyKitProvider>
)
}realtime: true opens a live stream for new inbox items; it also needs a realtime adapter on the server. Leave it out and the hooks load on mount and on refresh(). See Realtime and React.
Server Actions
To read and change a user’s inbox and preferences from server components or form actions, without the REST routes, create server actions with the same kind of identify(). Here it takes no arguments; read the session from cookies or headers.
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 Settings() {
const preferences = await notifyActions.getPreferences()
async function setEmail(formData: FormData) {
"use server"
await notifyActions.updatePreference({
notificationId: String(formData.get("notificationId")),
channels: { email: formData.get("enabled") === "on" },
})
}
return <form action={setEmail}>{/* one row per preference */}</form>
}The object it returns has:
| Method | Does |
|---|---|
getPreferences() | Every per-notification preference |
updatePreference({ notificationId, channels }) | Set channels for one notification |
getGlobalPreference(), updateGlobalPreference() | The recipient-wide channel switches |
getCategoryPreference(category), listCategoryPreferences(), updateCategoryPreference() | Per-category channel switches |
inbox.list(filter?) | Inbox items; { archived: true } for the archive |
inbox.unreadCount() | The unread count |
inbox.markRead(id), inbox.markAllRead() | Mark read |
inbox.archive(id), inbox.unarchive(id), inbox.deleteItem(id) | Archive, restore, or delete one item |
Every action looks up the user itself and checks its arguments, so they are safe to call with form data. Unlike the route, a missing session is an error: identify() must return an id, and throwing is how you refuse.
CORS Middleware
Only needed when the client is on another origin, such as a separate frontend or a mobile web view. For a single origin, the route’s own cors option is enough. The middleware accepts a list:
import { NextResponse, type NextRequest } from "next/server"
import { createNotifyKitMiddleware } from "@notifykitjs/next/middleware"
const withNotifyKit = createNotifyKitMiddleware({
cors: { origin: ["https://app.example.com", "https://admin.example.com"] },
})
export function middleware(request: NextRequest) {
return withNotifyKit(request) ?? NextResponse.next()
}
export const config = { matcher: "/api/notifykit/:path*" }It answers preflight requests itself, adds the CORS headers for allowed origins, and returns null for anything outside the base path or from another origin. Credentials are allowed unless the origin is * or you pass credentials: false. withNotifyKitHeaders() returns a next.config headers entry that adds nosniff, DENY framing, and no-store to the routes.
Testing the Routes
The handler is a plain (Request) => Promise<Response>, so tests can call it directly with no server. Pass basePath: "/" to keep the URLs short:
import { createHandler } from "@notifykitjs/core"
const handler = createHandler(notify, {
identify: () => "test_user",
basePath: "/",
})
const res = await handler(new Request("http://test/inbox"))
const { data: items } = await res.json()Responses are JSON with the result under data. See Testing and dev mode for the test harness.
Deploying
- Swap
memoryAdapter()for a database adapter and create its tables with a migration. - Swap the fake email provider for a real one. Shipping the inbox first and email in a later deploy isolates failures.
- Set
NOTIFYKIT_SECRET(openssl rand -hex 32) and pass it as bothunsubscribe.secretincreateNotifyKit()andunsubscribeSecreton the route. Changing it breaks every unsubscribe link already sent; there is no second key for rotation. - Set
unsubscribe.baseUrlto the route’s absolute URL, such ashttps://app.example.com/api/notifykit. - Send yourself a notification and check the inbox item, the email, and its unsubscribe link.
Serverless
Digests and quiet hours wait on in-process timers. On Vercel or Lambda the process may be gone before they fire, so the work stays in the database until something picks it up. Add a cron route:
import { notify } from "@/lib/notifykit"
export async function GET(request: Request) {
if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("unauthorized", { status: 401 })
}
await notify.recoverScheduledSends()
return Response.json({ ok: true })
}{ "crons": [{ "path": "/api/cron/notifykit", "schedule": "*/5 * * * *" }] }recoverScheduledSends() only sends messages whose quiet hours are over. Do not call flushScheduledSends() from a cron: with no arguments it sends everything, quiet hours or not. flushDigests() has the same catch: it closes every open digest, whether its window has ended or not, so calling it on a schedule caps your digest windows at the cron interval. Vercel sends CRON_SECRET as the bearer token when the variable is set.
| Platform | Realtime stream | Timers |
|---|---|---|
| Long-running Node, Docker, Fly, Railway | Works; set proxy idle timeouts above 30 seconds | Fire in process |
| Vercel Functions | Ends at the function's maximum duration, then reconnects | Cron route |
| AWS Lambda | Bounded by the timeout; prefer polling | EventBridge or SQS calling the same code |
Troubleshooting
| Symptom | Likely cause |
|---|---|
| 401 on every request | No session reaches identify(), or it returns an empty id |
| Hooks stay empty | No provider above them, a wrong baseUrl, or recipient ids that do not match |
| Routes return 404 | The route file is not at the handler’s basePath |
/inbox/stream | 404 means no realtime adapter on createNotifyKit(); 429 means ten streams are already open for that user |
| CORS errors | Set cors on the route or use the middleware, and export OPTIONS |
| Preferences revert on refresh | identify() returns different tenant or workspace ids between requests |
Set debug: true on the route during development and error responses include a fix field explaining what to change.