Getting started / Quickstart
Quickstart
A working inbox, email and push in five files. This walks the Postgres and Drizzle path. MongoDB differs only in step 2.
1 · Create the tables
The library owns four tables and never touches your user table. It references user ids instead.
// db/schema.ts
import { createSchema } from "easy-ping/adapters/drizzle";
export const { notification, notificationDelivery, notificationPreference } =
createSchema();import { coreSchema, renderPostgresDdl } from "easy-ping/schema";
for (const statement of renderPostgresDdl(coreSchema)) {
await sql.unsafe(statement);
}import { createMongoIndexes } from "easy-ping/adapters/mongodb";
// No tables, only indexes, including the partial unique index that makes
// dedupe work correctly. Run once at startup.
await createMongoIndexes(db);Push the Drizzle schema with drizzle-kit. On Postgres via raw SQL, read
Upgrading before you rely on renderPostgresDdl past the first deploy. It
only creates tables, it does not migrate them.
2 · Configure
Two things are yours and can't be guessed. How a request identifies its user, and how a user id becomes an email address.
export const notify = easyPing({
database: drizzleAdapter(db),
secret: process.env.NOTIFY_SECRET!,
// Required. These endpoints serve a user's private inbox.
session: {
getUserId: async (request) =>
(await auth.api.getSession({ headers: request.headers }))?.user.id ?? null,
},
// Batched. One call per send, never one per recipient.
getRecipients: async (userIds) => lookupUsers(userIds),
channels: {
inApp: { enabled: true },
email: { provider: resend({ apiKey: process.env.RESEND_API_KEY! }) },
},
notifications: {
commentReply: defineNotification({
schema: z.object({ authorName: z.string(), commentId: z.string() }),
channels: ["inApp", "email"],
}),
},
});session.getUserId is not optional. The mounted routes return one person's private inbox, so a
client-supplied user id can never be trusted.
See Configuration for the full option reference.
3 · Mount the endpoints
One line gives you the whole inbox API. Plugins add their own routes to the same mount.
export const { GET, POST } = notify.handler;| Route | Method | Does |
|---|---|---|
/ | GET | Paginated feed, cursor-based. Carries unseenCount on the first page |
/count | GET | Unseen badge count, as { unseen } |
/seen | POST | Clears the badge |
/read | POST | Marks items read, idempotently |
/cron | POST | The delivery sweep. Bearer-authenticated. |
4 · Send
Payload is typed against the schema you declared, so a rename fails at build rather than in production.
await notify.send("commentReply", {
to: threadOwnerId,
payload: { authorName: "Dana", commentId: "c_123" },
});5 · Render the bell
The hook is headless. It keeps itself fresh over a server-sent event stream (one per browser, polling as the fallback), handles cursor pagination and optimistic updates, and holds no opinion about your markup.
const { notifications, unseenCount, markAsRead, markSeen } = useNotifications();Seen and read are different states. Opening the dropdown clears the badge, but an item stays bold until it is actually clicked. See In-app inbox.
6 · Wire the cron
Every 1–5 minutes, from Vercel Cron, GitHub Actions, or anything that can POST. This is the durability floor beneath every delivery mode, as Delivery modes explains.
POST /api/notifications/cron
Authorization: Bearer $NOTIFY_CRON_SECRET