@byo-oss/notificationsv0.11.4plugin · inbox()

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
acme / dashboard
Inbox
  • Payment failed (unread)2m
    Your card was declined. Update billing to keep your plan.billing.payment_failed
  • Sam replied to your comment (unread)18m
    On "Q4 launch checklist"comment.reply
  • New sign-in1h
    Firefox on Linux. Not you? Review your sessions.security.new_login
  • You joined Acme1d
    An owner added you to the organization.org.member_added
<InboxButton /> · shadcn variables

Try it: the bell and "Mark all read" work.

Relies on

peerDependencies · @byo-oss/notifications 0.11.4
better-authrequired
peer · ^1.7.7

Users, sessions and your database adapter. The notification table migrates with it.

@better-auth/corerequired
peer · ^1.7.7

The after-commit hooks behind events. Install it at the same version as better-auth.

organizationoptional
better-auth plugin

Notify a whole org by role, keep inboxes per org, and clean up when an org or member is removed.

reactoptional
peer · >=18

The ./react hooks and <InboxButton />. Skip it for server-only use.

@byo-oss/maileroptional
BYO package

The email channel sends through any Mailer: your own SES, Resend, Postmark and more. Install it in your app to use email.

@wraps.dev/emailoptional
peer · >=0.15.1

Only for wrapsMailer, which sends through your own AWS SES account.

vitestoptional
peer · >=3

Only for ./testing, which runs a custom channel's contract suite.

[01] what you get

A plugin, not a platform.

01

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.

02

Addressed to users

notify({ userId }) takes your better-auth user ids, not email addresses. Delete a user and their notifications go with them.

03

Organization-aware

Notify a whole org, optionally filtered by role, through the better-auth organization plugin.

04

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.

05

One component

<InboxButton /> is the bell, the unread badge and the panel. Styled with shadcn variables, zero runtime dependencies.

06

Headless if you want

useInbox owns the state and the calls. Anything the component does, the hook can do in your own UI.

[02] quickstart

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.

  1. step 01

    Add the plugin

    To the better-auth config you already have.

    lib/auth.ts
    import { betterAuth } from "better-auth";
    import { inbox } from "@byo-oss/notifications";
    
    export const auth = betterAuth({
      // ...your existing config
      plugins: [inbox()],
    });
  2. step 02

    Migrate

    The plugin declares the notification table. Your existing workflow creates it: migrate, or generate for drizzle and prisma.

    terminal
    npx @better-auth/cli migrate --config lib/auth.ts
  3. step 03

    Add the client plugin

    So the browser can list and mark notifications.

    lib/auth-client.ts
    import { createAuthClient } from "better-auth/react";
    import { inboxClient } from "@byo-oss/notifications/client";
    
    export const authClient = createAuthClient({
      plugins: [inboxClient()],
    });
  4. 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} />;
    }
  5. 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.ts
    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}`,
      },
    });
Full quickstart in the docs →
[03] email

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
lib/auth.ts
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 }],
  },
});
[04] part of byo

Built to the conventions.

identity

better-auth users and organizations. No separate subscriber table to keep in sync.

access

notify is server-only. People list and mark only their own rows.

background work

One sweep, processDeliveries, called from any scheduler, or sweepAll from @byo-oss/core, which runs each BYO package's sweep. At-least-once delivery.

other packages

Proposed: every BYO package notifies through this inbox, so preferences live in one place.

[05] outgrowing this

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.

hereknocknovu
user.id, user.emailuser id, emailsubscriberId, email
organizationIdtenant idtenant context
typeworkflow keyworkflowId
title, body, href, datatrigger datatrigger payload
  1. 01 / run both

    Keep calling notify, and trigger the new tool for one type behind a flag.

  2. 02 / backfill people

    Bulk-import users, and organizations as tenants.

  3. 03 / cut over

    One type at a time, then point the bell at the tool's feed.

  4. 04 / keep history

    Leave the old inbox readable for as long as people need it.

  5. 05 / uninstall

    Once nothing calls notify and nothing reads the old inbox.

Full migration guide →