BackFrameworks

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
FileNeededWhat it does
lib/notifykit.tsYesYour definitions and createNotifyKit()
app/api/notifykit/[...route]/route.tsYesThe REST routes the React hooks call
app/(app)/layout.tsxYes<NotifyKitProvider>, so the hooks work
lib/notifykit-actions.tsNoServer actions, for server components and forms
middleware.tsNoCORS, 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:

app/api/notifykit/[...route]/route.ts
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:

ReturnResult
"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 undefined401, 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
},

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.ts
app/(app)/layout.tsx
import { 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.

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/(app)/settings/page.tsx
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:

MethodDoes
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:

middleware.ts
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 both unsubscribe.secret in createNotifyKit() and unsubscribeSecret on the route. Changing it breaks every unsubscribe link already sent; there is no second key for rotation.
  • Set unsubscribe.baseUrl to the route’s absolute URL, such as https://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:

app/api/cron/notifykit/route.ts
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 })
}
vercel.json
{ "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.

PlatformRealtime streamTimers
Long-running Node, Docker, Fly, RailwayWorks; set proxy idle timeouts above 30 secondsFire in process
Vercel FunctionsEnds at the function's maximum duration, then reconnectsCron route
AWS LambdaBounded by the timeout; prefer pollingEventBridge or SQS calling the same code

Troubleshooting

SymptomLikely cause
401 on every requestNo session reaches identify(), or it returns an empty id
Hooks stay emptyNo provider above them, a wrong baseUrl, or recipient ids that do not match
Routes return 404The route file is not at the handler’s basePath
/inbox/stream404 means no realtime adapter on createNotifyKit(); 429 means ten streams are already open for that user
CORS errorsSet cors on the route or use the middleware, and export OPTIONS
Preferences revert on refreshidentify() 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.