Users, sessions and your database adapter. The notification table migrates with it.
Notifications
In-app notifications that live in your database.
One plugin, one migration, one component. Notifications are addressed to your better-auth users, scoped to your organizations, and emailed only when nobody has read them in the app. Formerly better-inbox: same API, new imports.
npm i @byo-oss/notifications- Payment failed (unread)2mYour card was declined. Update billing to keep your plan.billing.payment_failed
- Sam replied to your comment (unread)18mOn "Q4 launch checklist"comment.reply
- New sign-in1hFirefox on Linux. Not you? Review your sessions.security.new_login
- You joined Acme1dAn owner added you to the organization.org.member_added
Try it: the bell and "Mark all read" work.
Relies on
peerDependencies · @byo-oss/notifications 0.11.4The after-commit hooks behind events. Install it at the same version as better-auth.
Notify a whole org by role, keep inboxes per org, and clean up when an org or member is removed.
The ./react hooks and <InboxButton />. Skip it for server-only use.
The email channel sends through any Mailer: your own SES, Resend, Postmark and more. Install it in your app to use email.
Only for wrapsMailer, which sends through your own AWS SES account.
Only for ./testing, which runs a custom channel's contract suite.
A plugin, not a platform.
In your database
The notification table lives in your database through better-auth's adapter. No services to deploy, no dashboard to sign up for.
Addressed to users
notify({ userId }) takes your better-auth user ids, not email addresses. Delete a user and their notifications go with them.
Organization-aware
Notify a whole org, optionally filtered by role, through the better-auth organization plugin.
Email when it matters
Route a type to email and it goes out only if it's still unread after a delay, or right away if the user isn't in the app.
One component
<InboxButton /> is the bell, the unread badge and the panel. Styled with shadcn variables, zero runtime dependencies.
Headless if you want
useInbox owns the state and the calls. Anything the component does, the hook can do in your own UI.
Five steps. One afternoon.
If you already run better-auth, you already have almost everything this needs. Add @better-auth/core at the same version.
- step 01
Add the plugin
To the better-auth config you already have.
lib/auth.tsimport { betterAuth } from "better-auth"; import { inbox } from "@byo-oss/notifications"; export const auth = betterAuth({ // ...your existing config plugins: [inbox()], }); - step 02
Migrate
The plugin declares the notification table. Your existing workflow creates it: migrate, or generate for drizzle and prisma.
terminalnpx @better-auth/cli migrate --config lib/auth.ts - step 03
Add the client plugin
So the browser can list and mark notifications.
lib/auth-client.tsimport { createAuthClient } from "better-auth/react"; import { inboxClient } from "@byo-oss/notifications/client"; export const authClient = createAuthClient({ plugins: [inboxClient()], }); - step 04
Drop in the component
Anywhere in a client component, like your navbar.
components/navbar.tsx"use client"; import { InboxButton } from "@byo-oss/notifications/react"; import { authClient } from "@/lib/auth-client"; export function Navbar() { return <InboxButton client={authClient} />; } - step 05
Send one
From any server code: a server action, a webhook handler, a cron job. notify is server-only and never exposed as an HTTP route.
app/actions.tsawait auth.api.notify({ body: { userId: comment.authorId, type: "comment.reply", title: `${user.name} replied to your comment`, href: `/posts/${post.id}#comment-${comment.id}`, }, });
Email is the follow-up, not the default.
The in-app row is always written. Route a type to email and it's sent only if nobody read or saw it first, through any mailer: your own AWS SES account, Resend, Postmark and more. Every supported provider.
- ifUnreadAfter: wait, then send if still unread
- cancelIfRead: false to always send
- throttle: at most one email per window
import { inbox } from "@byo-oss/notifications";
import { mailerChannel } from "@byo-oss/notifications/mailer";
import { wrapsMailer } from "@byo-oss/mailer/wraps";
inbox({
appUrl: "https://app.acme.com",
channels: {
email: mailerChannel({ mailer: wrapsMailer({ from: "Acme <notify@acme.com>" }) }),
},
routes: {
// email after 15 minutes if still unread
"billing.payment_failed": [{ channel: "email", ifUnreadAfter: { minutes: 15 } }],
// in-app and email, both right away
"security.new_login": [{ channel: "email", cancelIfRead: false }],
},
});Built to the conventions.
better-auth users and organizations. No separate subscriber table to keep in sync.
notify is server-only. People list and mark only their own rows.
One sweep, processDeliveries, called from any scheduler, or sweepAll from @byo-oss/core, which runs each BYO package's sweep. At-least-once delivery.
Proposed: every BYO package notifies through this inbox, so preferences live in one place.
When it's time for Knock or Novu.
You've outgrown it when you need push, SMS or chat next to in-app, a preference center and digests across channels, non-engineers editing templates, or realtime delivery to thousands of concurrent users. Moving is a forward cutover, and your notification table stays the archive.
| here | knock | novu |
|---|---|---|
| user.id, user.email | user id, email | subscriberId, email |
| organizationId | tenant id | tenant context |
| type | workflow key | workflowId |
| title, body, href, data | trigger data | trigger payload |
- 01 / run both
Keep calling notify, and trigger the new tool for one type behind a flag.
- 02 / backfill people
Bulk-import users, and organizations as tenants.
- 03 / cut over
One type at a time, then point the bell at the tool's feed.
- 04 / keep history
Leave the old inbox readable for as long as people need it.
- 05 / uninstall
Once nothing calls notify and nothing reads the old inbox.