notifications
A reminder to approve an invoice, a note that an order has shipped, a nudge before a meeting: some things should reach people even when your tab is in the background. @kitr/notifications gives you one notifications service to ask for permission, show a notification now or at a set time, and find out which one was tapped so the app opens the right page.
In the browser it is built on the Notification API, with timers for scheduling. kitr adds stable ids you can cancel by, a url carried from the notification to its tap, a React hook for the permission state, a memory driver for tests and a driver slot for native shells.
It runs in the browser and in desktop and mobile shells; on the server the service shows nothing. It covers notifications the app shows itself. Push notifications sent from your server while the app is closed need a push service (APNs, FCM or Web Push) on top.
This guide follows Acme's billing screen at app.acme.com: Ada turns on reminders, schedules one to approve invoice inv_1042, and lands on that invoice when she taps it.
Install
kitr add notificationsFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
What kitr add does:
- Installs
@kitr/notificationsand adds it topackagesinkitrrc.ts. - Adds
() => import('@kitr/notifications/provider')toprovidersinkitrrc.ts. - Writes
config/notifications.ts(shown under Configure). - Adds a
## kitr: notificationssection toAGENTS.md:useNotificationPermission()gives{ permission, request }andrequestis called from a click,show({ title, url })andschedule({ ..., at })resolve to an id forcancel, and taps are routed once near the router withuseNotificationTap.
It adds no environment variables or scripts. kitr add notifications asks no questions, and no kit includes it. To preview the changes:
kitr add notifications --dry-runFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
kitr remove notifications removes the provider, the packages entry, the dependency, the AGENTS.md section and config/notifications.ts, then lists the files that still import @kitr/notifications.
Configure
kitr add writes this file:
// config/notifications.ts
import { defineConfig } from '@kitr/notifications'
export default defineConfig({
icon: '/icon-192.png',
// Ask from a click (browsers block unprompted asks); true asks at boot instead.
requestOnBoot: false,
})
| Option | Default | Description |
|---|---|---|
icon |
none | Icon for browser notifications that don't set their own. A path under public/ or a full URL. |
requestOnBoot |
false |
Ask for permission as the app starts in the browser. Browsers block or bury asks that don't follow a click, so leave it off for websites. |
driver |
registered, else the web driver | A NotificationsDriver that replaces the one the service would pick. For tests and custom shells. |
The service picks its driver in this order: driver from the config, a driver a provider registered under notifications, then the web driver in a browser, or a memory driver whose permission is 'unsupported' on the server.
Point icon at a file that exists. The Web platform's PWA manifest uses /icons/icon-192.png by default, not the /icon-192.png that kitr add writes, so with pwa on you usually want icon: '/icons/icon-192.png'. A missing icon shows the notification with the browser's default image.
Send your first notification
Ask for permission from a button, then show a notification:
// app/components/invoices/notify-me.tsx
import { useNotificationPermission, useNotifications } from '@kitr/notifications/react'
export function NotifyMe() {
const { permission, request } = useNotificationPermission()
const notifications = useNotifications()
if (permission === 'unsupported' || permission === 'denied') return null
if (permission !== 'granted')
return (
<button type="button" onClick={request}>
Turn on notifications
</button>
)
return (
<button
type="button"
onClick={() =>
notifications.show({
title: 'Invoice inv_1042 is due',
body: 'Acme Corp, $1,280.00, due Friday',
url: '/invoices/inv_1042',
})
}
>
Test a notification
</button>
)
}
Ada clicks Turn on notifications, the browser asks, and she allows it. The button changes to Test a notification; clicking it shows a system notification titled "Invoice inv_1042 is due" with Acme's icon. Routing the tap to /invoices/inv_1042 is covered in Open the right page when tapped.
Ask for permission
useNotificationPermission() returns { permission, request }. permission is 'unknown' on the first render and on the server, then one of four states:
| State | Meaning | What to show |
|---|---|---|
'prompt' |
Not asked yet, or the person closed the prompt. | A button that calls request. |
'granted' |
Notifications will show. | Your reminder controls. |
'denied' |
Blocked. The page cannot ask again. | Nothing, or how to unblock it in the browser's site settings. |
'unsupported' |
No Notification API here, or the server. | Nothing. |
request() resolves to the new state, so you can act on the answer in the same handler:
// app/components/invoices/enable-reminders.tsx
import { useNotificationPermission } from '@kitr/notifications/react'
export function EnableReminders({ onEnabled }: { onEnabled: () => void }) {
const { permission, request } = useNotificationPermission()
if (permission === 'denied')
return <p>Reminders are blocked. Allow notifications for app.acme.com in your browser settings.</p>
if (permission !== 'prompt') return null
async function enable() {
const state = await request()
// => 'granted' or 'denied', or 'prompt' when Ada closed the prompt
if (state === 'granted') onEnabled()
}
return (
<button type="button" onClick={enable}>
Remind me about due invoices
</button>
)
}
Call request from a click. Browsers ignore or quietly block permission prompts that no gesture started, and Safari requires one. Don't call it from an effect on page load; ask when the person does something that needs a notification.
For an installed app or a kiosk where an ask at start is expected, set requestOnBoot: true. The provider then calls request() as the app boots in the browser.
Outside React, notifications.permission() reads the state without asking.
Show a notification now
notifications.show(options) resolves to the notification's id:
// app/lib/orders/notify-shipped.ts
import type { Notifications } from '@kitr/notifications'
export async function notifyShipped(notifications: Notifications, orderId: string) {
const id = await notifications.show({
title: `Order ${orderId} has shipped`,
body: 'Arriving Thursday at Acme HQ',
url: `/orders/${orderId}`,
data: { orderId },
})
// => 'n-m5x2k1qz-0' (generated)
return id
}
| Option | Default | Description |
|---|---|---|
title |
required | The first line. |
body |
none | The text under the title. |
url |
none | Where a tap should go, such as /orders/ord_3001. Handed to tap listeners. |
id |
generated | Your own id, to replace or cancel this notification later. |
icon |
config.icon |
The image beside the text. |
data |
none | Extra values handed to tap listeners and passed to native drivers. |
channel |
the id | A group. On the web it is the notification's tag, so a new one in the same channel replaces the old. On Android it is the notification channel. |
silent |
false |
Show it without sound or vibration. |
On the web, show() does nothing while permission is not 'granted', and still resolves to an id. Check permission before you rely on a notification being seen.
Use it for events that happen while the app is open, such as a realtime message saying an order shipped. When the app is closed, nothing on the device runs your code, so only a push service can notify.
Replace a notification
Give a notification your own id to update it instead of stacking a second one. Showing the same id again closes the first:
// app/lib/orders/orders-export.ts
import type { Notifications } from '@kitr/notifications'
export async function reportExport(notifications: Notifications, done: number, total: number) {
await notifications.show({
id: 'orders-export',
title: done < total ? `Exporting orders: ${done} of ${total}` : 'Orders export ready',
url: done < total ? undefined : '/orders/exports',
silent: done < total,
})
}
Each call replaces the last, so Ada sees one notification that ends as "Orders export ready". channel does the same for notifications with different ids: the browser keeps one notification per tag.
Schedule a notification
notifications.schedule(options) takes everything show takes plus at, a Date or a timestamp in milliseconds, and resolves to the id:
// app/components/invoices/remind-later.tsx
import { useNotifications } from '@kitr/notifications/react'
export function RemindLater({ invoiceId }: { invoiceId: string }) {
const notifications = useNotifications()
async function remind() {
const id = await notifications.schedule({
id: `approve-${invoiceId}`,
title: `Approve invoice ${invoiceId}`,
body: 'Grace is waiting on your approval',
url: `/invoices/${invoiceId}`,
at: Date.now() + 60 * 60 * 1000,
})
// => 'approve-inv_1042'
return id
}
return (
<button type="button" onClick={remind}>
Remind me in an hour
</button>
)
}
A time in the past shows the notification straight away. Scheduling an id that is already scheduled replaces it, so approve-inv_1042 can only fire once. An invalid at, such as new Date('next week') or NaN, rejects:
// app/lib/invoices/bad-reminder.ts
import type { Notifications } from '@kitr/notifications'
export async function badReminder(notifications: Notifications) {
await notifications.schedule({ title: 'Approve invoice inv_1042', at: new Date('next week') })
// throws Error: [@kitr/notifications] schedule() needs a valid `at`, got Invalid Date
}
Warning On the web, scheduled notifications live in a timer inside the page. They are lost when the tab closes or reloads, and browsers slow timers in background tabs. For a reminder that must arrive, schedule it on your server and deliver it with Web Push, or use a native driver that hands it to the operating system.
The web driver waits in steps, so a reminder 40 days out does not fire early when it passes the browser's 24.8-day timer limit.
Cancel notifications
Cancel by the id that show or schedule resolved to, list what is still waiting, or clear everything:
// app/lib/invoices/approve.ts
import type { Notifications } from '@kitr/notifications'
export async function onInvoiceApproved(notifications: Notifications, invoiceId: string) {
await notifications.cancel(`approve-${invoiceId}`)
const waiting = await notifications.pending()
// => ['approve-inv_1043'], the ids scheduled and not yet shown
return waiting
}
export async function onSignOut(notifications: Notifications) {
await notifications.cancelAll()
}
cancel(id) stops a scheduled notification and, on the web, closes it if it is on screen. cancelAll() does both for every notification this page showed or scheduled. Call it when someone signs out, so Linus doesn't get Ada's reminders on a shared computer. pending() lists only scheduled notifications that haven't fired; shown ones are not in it.
Open the right page when tapped
useNotificationTap(handler) calls handler with { id, url, data } whenever a notification is clicked. Mount it once, inside the router, so every tap navigates:
// app/components/notification-router.tsx
import { useNotificationTap } from '@kitr/notifications/react'
import { useNavigate } from '@kitr/router'
import { useCallback } from 'react'
export function NotificationRouter() {
const navigate = useNavigate()
const onTap = useCallback(
(tap: { id: string; url?: string }) => {
if (tap.url) void navigate({ to: tap.url })
},
[navigate],
)
useNotificationTap(onTap)
return null
}
// app/document.tsx
import { HeadContent, Outlet, Scripts } from '@kitr/router'
import { NotificationRouter } from '#app/components/notification-router'
export function RootDocument() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<NotificationRouter />
<Outlet />
<Scripts />
</body>
</html>
)
}
Ada clicks "Approve invoice inv_1042", the browser focuses the Acme tab, closes the notification and the app moves to /invoices/inv_1042. Wrapping the handler in useCallback keeps the hook from re-subscribing on every render.
Don't mount useNotificationTap in each page that shows notifications: a tap on a notification from another page would then go nowhere. One listener near the router handles them all.
Outside React, notifications.onTap(listener) returns a function that removes the listener:
// app/lib/invoices/track-taps.ts
import type { Notifications } from '@kitr/notifications'
export function trackReminderTaps(notifications: Notifications, record: (id: string) => void) {
return notifications.onTap((tap) => {
if (tap.id.startsWith('approve-')) record(tap.id)
})
}
Show notifications on Chrome for Android
Chrome on Android does not let a page construct a Notification. When the constructor throws and a service worker controls the page, the web driver shows the notification through the worker instead. Turn on pwa in the Web platform so the app has one:
// kitrrc.ts
import { defineKitrConfig } from 'kitr/app'
import { web } from '@kitr/web'
export default defineKitrConfig({
// ...
platforms: {
site: web({ mode: 'spa', pwa: true }),
},
})
Taps on those notifications reach the worker's notificationclick event, not onTap. The notification's data holds { id, url, data }, so a custom worker can open url. Without a controlling service worker, show() on Chrome for Android rejects with the browser's TypeError: Illegal constructor.
Use it outside React
Read the service from the app's container. useNotifications(app) from @kitr/notifications does the same thing:
// app/lib/orders/on-shipped.ts
import type { KitrApp } from 'kitr/app'
import { useNotifications } from '@kitr/notifications'
export async function onOrderShipped(app: KitrApp, orderId: string) {
const notifications = useNotifications(app) // same as app.container.use('notifications')
if ((await notifications.permission()) !== 'granted') return
await notifications.show({ title: `Order ${orderId} has shipped`, url: `/orders/${orderId}` })
}
To build a service with no app, call createNotifications(config). In a browser it uses the web driver; in Node, the memory driver with 'unsupported' permission. When the Devtools provider is installed, the service logs each permission answer, show, schedule and tap under notifications.
Where notifications come from
| Where it runs | Driver | Scheduled notifications |
|---|---|---|
| Browser | createWebNotificationsDriver(), the Notification API |
Fire only while the page is open. |
| Chrome for Android | The same driver, through the service worker | The same. |
| Desktop and mobile shells | The web driver inside the webview, unless a provider registers one | The same, until a native driver is registered. |
| Server | Memory driver, permission 'unsupported' |
Nothing is shown. |
Today Desktop (Electron), Tauri and Mobile register only platform and storage drivers. To hand notifications to the operating system, so scheduled ones fire with the app closed, register a notifications driver as shown under Extend.
Errors
| Error | When | What to do |
|---|---|---|
[@kitr/notifications] schedule() needs a valid `at` |
at is an invalid Date or NaN. |
Pass a Date or a timestamp in milliseconds. |
TypeError: Illegal constructor (browser) |
Chrome for Android, with no service worker controlling the page. | Turn on pwa in the web platform. |
A show() without permission is not an error: it resolves and shows nothing. A denied permission is a state, not an exception.
Testing
createMemoryNotificationsDriver(permission) records everything and shows nothing. Its permission starts at the state you pass (default 'granted'); request() turns 'prompt' into 'granted' and leaves every other state as it is. It adds three things to the driver:
| Member | Description |
|---|---|
shown |
Every notification passed to show, with its id. |
scheduled |
A Map of id to the scheduled notification, emptied by cancel and cancelAll. |
tap(id) |
Simulates a tap on a shown or scheduled notification, calling every tap listener with its url and data. |
Hand it to the app through config.notifications.driver:
// app/components/invoices/remind-later.test.tsx
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { createMemoryNotificationsDriver } from '@kitr/notifications'
import { notificationsProvider } from '@kitr/notifications/provider'
import { useNotificationTap } from '@kitr/notifications/react'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { describe, expect, it, vi } from 'vitest'
import { EnableReminders } from './enable-reminders'
import { RemindLater } from './remind-later'
describe('invoice reminders', () => {
it('asks for permission from the button', async () => {
const driver = createMemoryNotificationsDriver('prompt')
const app = await createTestApp({
providers: [notificationsProvider],
config: { notifications: { driver } },
})
const onEnabled = vi.fn()
renderWithKitr(<EnableReminders onEnabled={onEnabled} />, { app })
fireEvent.click(await screen.findByRole('button', { name: 'Remind me about due invoices' }))
await waitFor(() => expect(onEnabled).toHaveBeenCalled())
expect(await driver.permission()).toBe('granted')
})
it('schedules one reminder per invoice and routes the tap', async () => {
const driver = createMemoryNotificationsDriver()
const app = await createTestApp({
providers: [notificationsProvider],
config: { notifications: { driver } },
})
const opened: string[] = []
function Taps() {
useNotificationTap((tap) => tap.url && opened.push(tap.url))
return null
}
renderWithKitr(
<>
<Taps />
<RemindLater invoiceId="inv_1042" />
</>,
{ app },
)
const button = screen.getByRole('button', { name: 'Remind me in an hour' })
fireEvent.click(button)
fireEvent.click(button)
await waitFor(() => expect(driver.scheduled.size).toBe(1))
expect(driver.scheduled.get('approve-inv_1042')).toMatchObject({
title: 'Approve invoice inv_1042',
url: '/invoices/inv_1042',
})
driver.tap('approve-inv_1042')
expect(opened).toEqual(['/invoices/inv_1042'])
})
})
To test code that takes the service directly, build one with createNotifications:
// app/lib/invoices/approve.test.ts
import { createMemoryNotificationsDriver, createNotifications } from '@kitr/notifications'
import { expect, it } from 'vitest'
import { onInvoiceApproved } from './approve'
it('cancels the reminder once the invoice is approved', async () => {
const driver = createMemoryNotificationsDriver()
const notifications = createNotifications({ driver })
await notifications.schedule({ id: 'approve-inv_1042', title: 'Approve', at: Date.now() + 1000 })
await notifications.schedule({ id: 'approve-inv_1043', title: 'Approve', at: Date.now() + 1000 })
expect(await onInvoiceApproved(notifications, 'inv_1042')).toEqual(['approve-inv_1043'])
})
To test the web driver itself, stub the global Notification class with vi.stubGlobal('Notification', ...) and use fake timers for schedule.
Extend
A driver implements NotificationsDriver. The service generates ids before it calls show and schedule, so a driver always receives one:
interface NotificationsDriver {
name: string
permission(): Promise<NotificationPermission> // 'granted' | 'denied' | 'prompt' | 'unsupported'
request(): Promise<NotificationPermission>
show(options: NotificationOptions & { id: string }): Promise<void>
schedule(options: ScheduleOptions & { id: string }): Promise<void>
cancel(id: string): Promise<void>
cancelAll?(): Promise<void> // optional: the service falls back to cancel() on each pending id
pending?(): Promise<string[]> // optional: the service answers [] without it
onTap(listener: (tap: NotificationTap) => void): () => void
}
This driver hands notifications to iOS and Android through @capacitor/local-notifications, so scheduled ones fire with the app closed. Capacitor needs whole-number ids, so the driver hashes kitr's string id and keeps the original in extra. Install it with pnpm add @capacitor/local-notifications, then create the provider with kitr make:provider notifications-driver:
// app/providers/notifications-driver-provider.ts
import { Capacitor } from '@capacitor/core'
import { LocalNotifications } from '@capacitor/local-notifications'
import { defineProvider } from 'kitr/app'
import type { NotificationPermission, NotificationsDriver } from '@kitr/notifications'
const numeric = (id: string) => {
let hash = 0
for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0
return Math.abs(hash)
}
const toState = (display: string): NotificationPermission =>
display === 'granted' ? 'granted' : display === 'denied' ? 'denied' : 'prompt'
function capacitorNotifications(): NotificationsDriver {
const post = (o: Parameters<NotificationsDriver['show']>[0], at?: Date) =>
LocalNotifications.schedule({
notifications: [
{
id: numeric(o.id),
title: o.title,
body: o.body ?? '',
channelId: o.channel,
schedule: at ? { at } : undefined,
extra: { id: o.id, url: o.url, data: o.data },
},
],
}).then(() => undefined)
return {
name: 'capacitor',
permission: async () => toState((await LocalNotifications.checkPermissions()).display),
request: async () => toState((await LocalNotifications.requestPermissions()).display),
show: (o) => post(o),
schedule: (o) => post(o, new Date(o.at)),
cancel: (id) => LocalNotifications.cancel({ notifications: [{ id: numeric(id) }] }),
pending: async () =>
(await LocalNotifications.getPending()).notifications.map((n) => String(n.extra?.id ?? n.id)),
onTap(listener) {
const handle = LocalNotifications.addListener('localNotificationActionPerformed', (event) => {
const extra = event.notification.extra ?? {}
listener({ id: extra.id ?? String(event.notification.id), url: extra.url, data: extra.data })
})
return () => void handle.then((h) => h.remove())
},
}
}
export default defineProvider({
name: 'notifications-driver',
register(app) {
if (Capacitor.isNativePlatform())
app.drivers.register('notifications', capacitorNotifications(), { platform: 'mobile' })
},
})
Add () => import('#app/providers/notifications-driver-provider') to providers in kitrrc.ts. Inside the iOS or Android app, notifications.driver.name is now 'capacitor' and Ada's reminder fires even after she closes the app; in the browser, the web driver still applies. The NotificationsDriver, NotificationOptions and NotificationTap types are in the API reference.
Next steps
- API reference
- Notifications API on MDN: what each browser supports.
- Web: the
pwaoption that adds the service worker Chrome for Android needs. - Realtime: learn about a shipped order while the app is open, then show a notification.
- Jobs: schedule reminders on the server, where they survive a closed tab.
- Mobile: the Capacitor shell that a native driver plugs into.