better-inbox: in-app notifications that live in your database
Every app eventually needs a bell icon. A little badge with an unread count, a panel that drops down, a "payment failed — update your card" that a user can actually see without checking their email. It's table stakes, and it's more annoying to build than it has any right to be.
Your options are usually one of two extremes. Reach for a notifications platform like Novu — powerful, but now you're deploying a service, wiring webhooks, and syncing your users into someone else's system just to render a dropdown. Or roll your own — a table, five endpoints, a polling hook, a component, and the realization halfway through that you've forgotten to scope the "mark as read" mutation to the current user.
Both are wrong for the common case. If you already use better-auth, you already have everything a notification system needs: users, organizations, a database adapter, session handling, and a plugin system. Notifications shouldn't be a platform. They should be a plugin.
So I built one. It's called better-inbox, and the whole pitch is three lines: one plugin, one migration, one component.
The core idea: notifications you own
The thing I care about most is where the data lives. With better-inbox, the notification table is created in your database through better-auth's adapter — drizzle, prisma, kysely, whatever you already run. There's no external service, no dashboard SaaS, no account to provision. Notifications are just rows in your database that you can query, back up, and delete like anything else you own.
And they're addressed to your users, not to email addresses:
await auth.api.notify({
body: {
userId: comment.authorId,
type: "comment.reply",
title: `${user.name} replied to your comment`,
href: `/posts/${post.id}#comment-${comment.id}`,
},
});Because notifications reference better-auth user ids through a foreign key, they behave the way you'd want: delete a user and their notifications cascade away with them. No orphaned rows, no cleanup job. The identity system you already trust becomes the addressing system for notifications.
Getting it running
The setup is deliberately boring, because boring is the feature. Add the plugin to your better-auth config:
// lib/auth.ts
import { betterAuth } from "better-auth";
import { inbox } from "better-inbox";
export const auth = betterAuth({
// ...your existing config
plugins: [inbox()],
});Run your normal migration — the plugin declares the notification table, and better-auth's CLI creates it:
npx @better-auth/cli migrate --config lib/auth.ts
Add the client plugin next to your other better-auth client plugins:
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { inboxClient } from "better-inbox/client";
export const authClient = createAuthClient({
plugins: [inboxClient()],
});Then drop the component into your navbar:
"use client";
import { InboxButton } from "better-inbox/react";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth-client";
export function Navbar() {
const router = useRouter();
return <InboxButton client={authClient} onNavigate={(href) => router.push(href)} />;
}That's the bell, the unread badge, and the inbox panel — done. From this point on you notify() from any server code, and it shows up.
Notify a whole organization
Most B2B events aren't addressed to one person — they're addressed to a company. A failed payment needs to reach the org's owners and admins, not whichever teammate happened to trigger the charge. So if you have the better-auth organization plugin enabled, you can address an org instead of a user:
// Stripe webhook: payment failed → tell the org's admins
await auth.api.notify({
body: {
organizationId: subscription.metadata.orgId,
roles: ["owner", "admin"], // optional — omit to notify every member
type: "billing.payment_failed",
title: "Payment failed — update your card",
href: "/settings/billing",
},
});This is fan-out on write: one row is created per member, each stamped with the organizationId. That's a deliberate trade. Fan-out on write means the list query stays trivial — every user reads their own notifications, no joins across membership tables at read time — at the cost of writing N rows up front. For notifications, where writes are rare and reads are constant (that badge polls on every page), that's the right side of the trade.
The obvious failure mode is a 50,000-member org turning one webhook into a write storm. So fan-out is capped — 1,000 members by default, configurable with inbox({ maxFanout }). Over the cap, notify throws instead of quietly hammering your database. I'd rather you get a loud error and make a decision than discover the problem in a slow-query dashboard.
The decisions that actually matter
The API surface is small on purpose. But a few choices underneath it are worth calling out, because they're the difference between a demo and something you'd put in production.
notify is server-only. It's callable through auth.api from a server action, a webhook handler, or a cron job — and it is never exposed as an HTTP route. There is no way for a browser to POST a notification. Creating notifications is a privileged operation, so it lives on the privileged side of the boundary, full stop.
Every read is scoped to the caller. The session-facing endpoints — list, mark read, mark all read, unread count — are all scoped to the authenticated user. A user can only ever see and mutate their own notifications. This is the part everyone gets wrong in the roll-your-own version: you build markRead(id), ship it, and forget that id is attacker-controlled. Here the scoping isn't something you remember to add; it's the only behavior available.
Polling, not websockets. The React layer polls the unread count (every 30s by default) and does a full refresh on window focus and when the panel opens. No websocket server, no realtime infrastructure, no connection to keep alive. For a notification badge, this covers it honestly — you find out about the payment-failed notification within 30 seconds or the moment you tab back, which is exactly as fast as anyone needs. I'd rather ship the honest version than a websocket layer you have to operate.
Zero runtime dependencies in the component. <InboxButton /> is styled with shadcn CSS variables so it inherits any shadcn theme automatically — but it doesn't require shadcn, and it ships its own zero-dependency popover. If you want to build your own UI, useInbox() hands you the state and mutations and gets out of your way:
const { notifications, unreadCount, markRead, markAllRead, loadMore, hasMore } = useInbox(authClient);What it deliberately is not
There's a section in the README titled "What this is not (yet)," and I want to repeat it here because scope discipline is the whole reason this thing is usable.
better-inbox does not do email, push, SMS, notification preferences, digest batching, or realtime websockets. None of it. Those belong to a multi-channel delivery pipeline, and that's a genuinely different — and much bigger — product. If you need that, Novu and betternotify exist and are good at it.
better-inbox is the thin, database-owned layer for the in-app half — the bell, the badge, the panel, and the rows behind them. Doing one thing means the API stays small enough to learn in a sitting, and honest polling is enough to make the badge correct. The moment you add channels, you inherit provider config, retries, and delivery status, and the "one plugin, one migration, one component" promise is gone.
One honest caveat that ships in the box: plugin schemas can't declare indexes, so once you have real traffic you'll want to add a composite index on (userId, createdAt) by hand. It's one line in your migration, and it's documented rather than hidden.
How it got built
Two things about the process are worth mentioning, because they're both dogfooding.
First, better-inbox was built end to end with the agent SDLC I wrote about earlier — the same idea → research → plan → build → review lifecycle. The repo still carries the artifacts: the v0.1 idea and research briefs, the implementation plan, and a build log. It's a small, self-contained library, which made it a clean test of whether the process holds up on a greenfield package and not just inside an existing codebase. It did.
Second, the demo GIF in the README isn't a screen recording. It's a Remotion video that renders the actual InboxPanel component — the same React component the library ships — driven through a scripted sequence: a Stripe webhook fires, the bell rings, the panel opens on the payment-failed notification. The demo can't drift from the real component because it is the real component. When the UI changes, re-rendering the video keeps the marketing honest.
And then the real test: it's running in Wraps. The dashboard bell, the billing fan-out to org owners, the in-app "new device signed into your account" alert — all better-inbox. Shipping your own library into your own production app is the fastest way to find the sharp edges, and it's why v0.1 already has a fan-out cap and server-only notify instead of learning those lessons in someone else's bug report.
Try it
npm install better-inbox
It's MIT licensed and lives at github.com/better-inbox/better-inbox. It's a community plugin — not affiliated with the better-auth team — built because I needed a bell icon and didn't want to deploy a platform to get one.
If you're already on better-auth, it's about five minutes from npm install to a working inbox. If you're not, well, this is one more small reason the plugin ecosystem is the good part.
I'm building Wraps — email, SMS, and CDN infrastructure that deploys to your own AWS account. better-inbox came out of needing in-app notifications there, and now it's open source for anyone on better-auth.