Add a notification inbox to your React app
The bell, the badge, the unread count, the live updates. Here is the whole integration end to end, including the two things that work perfectly the first time you test them and break on every page load after that.
An in-app notification inbox looks like a list of rows. Then you write it.
You need the rows, a per-user read state, an unread count that never disagrees with the list, some way for that count to change without a refresh, a preferences screen so people can turn things off, and an API where one user cannot read another user's rows. Then someone opens your app in two tabs and you find out the badge was per-tab state all along.
This post is the shorter version: a working inbox in a React app, bell and badge and live arrival, in about fifteen minutes. I will flag the two places real integrations break, because both of them pass your first test.
What you end up with
A bell in your header with an unread badge, a feed you can drop on a page, and notifications
that appear the moment your backend fires one. No polling loop, no refresh button. Put the bell
in the header and the feed on a /notifications page and they share
one store and one connection, so marking something read in one place updates the other with no
wiring from you.
You need three things to follow along: an Elaan account (free tier, no card), a React app, and a backend you can add one route to. That last one is not optional, and step two is where I explain why.
Step 1: create the type and its inbox template
Two concepts, and confusing them is the first thing that goes wrong. A
notification type is the event your product emits:
order_shipped, comment_reply,
invoice_ready. A template is how one type reads on
one channel. Create the type once, then give it an inbox template.
In the console that is Notification Types, then Inbox Templates. The template is a title and a body with slots in them:
Inbox template for comment_replyTitle {{ actor_name }} replied to you
Body {{ comment_excerpt }}
Slots are filled at send time from three places, and the slot itself tells you which.
{{ contact.first_name }} comes from the recipient's own record,
{{ brand.name }} from their brand, and a bare
{{ actor_name }} from the variables on the trigger. A slot nobody
fills renders empty rather than failing the send, so adding one does not break callers who have
not started sending it yet.
202. That 202 means recorded, not delivered.
Fan-out runs a moment later in the background, finds nothing to render, and records the failure
against the event rather than against your API call. If you fire a test notification and nothing
lands, check this before you check anything else.
Step 2: mint contact tokens from your backend
Your service key (sk_…) authorizes everything: triggering sends,
managing contacts, reading any inbox in your account. It belongs on your server and nowhere
else. The browser gets a contact token instead, which is short-lived, scoped to
exactly one contact, and cannot reach a single management route.
So you add one route. Your own session decides who is asking, and you exchange that for a token.
Your backend, one routeapp.get("/api/elaan-token", async (req, res) => {
// req.user comes from your session, never from the client
const mint = () => fetch("https://api.elaan.io/v1/contacts/tokens", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ELAAN_SERVICE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ external_id: req.user.id }),
});
let r = await mint();
if (r.status === 404) { // this user isn't synced yet
await createContact(req.user); // POST /v1/contacts
r = await mint();
}
res.json(await r.json()); // { token, contact_id, expires_in }
});
That 404 branch is the second thing that breaks. Elaan addresses people by your own
external_id, so a user you have never synced has no contact to mint
a token for.
The obvious fix is to call POST /v1/contacts first and then mint.
Do not. That route creates, it does not upsert, so it returns
409 the second time and an endpoint written that way works on a
user's first page load and fails on every one after it. Mint first and create on the 404
instead. Creation is the exception rather than the rule, so you pay one request per page load
instead of two, forever.
POST /v1/contacts/bulk
with a single row is an upsert. It matches on external_id and leaves
any field you omit alone, so it is safe to run on every login without wiping the preferences the
contact set themselves.
Step 3: the React side
npm install @elaanio/react
Then wrap the part of your app that needs notifications and drop the components in:
App.tsximport { ElaanProvider, NotificationBell, NotificationFeed } from "@elaanio/react";
import "@elaanio/react/styles.css";
async function tokenProvider() {
const res = await fetch("/api/elaan-token", { credentials: "include" });
const { token, contact_id } = await res.json();
return { token, contactId: contact_id };
}
export function App() {
return (
<ElaanProvider apiBase="https://api.elaan.io/v1" tokenProvider={tokenProvider}>
<Header><NotificationBell /></Header>
<NotificationFeed />
</ElaanProvider>
);
}
Notice the SDK never calls Elaan's token endpoint itself. It calls your function, which hits your route, where your session decides who the caller is. That indirection is the whole security model: the contact identity comes from your server, never from anything the browser claims. The SDK calls your function again when the token expires, so keep it cheap and idempotent.
Realtime is on by default over server-sent events, with a polling fallback if the stream cannot be established. If you want the argument for SSE over WebSockets here, I wrote that up separately in your notification inbox doesn't need WebSockets.
Step 4: fire one and watch it land
From your backend, or from a terminalcurl https://api.elaan.io/v1/notifications \
-H "Authorization: Bearer $ELAAN_SERVICE_KEY" \
-H "Content-Type: application/json" \
-d '{ "notification_type_key": "comment_reply",
"external_ids": ["user_42"],
"variables": { "actor_name": "Grace", "comment_excerpt": "Ship it." } }'
Leave the browser tab open while you run it. The badge should increment on its own, with no refresh and no click. That is the whole integration.
What you did not have to write
Worth naming, because this is the part that looks like an afternoon in a ticket and is not.
The bell and the feed read one store, so the count and the list cannot drift apart. Read state is optimistic and reconciles against the server, so a click feels instant but a failed write does not leave a lie on the screen. The stream reconnects and falls back to polling without being asked. And when you add the preferences component, it offers a contact only the channels that type can actually reach, decided by which templates exist rather than by a list you keep in step by hand. On why that screen is a grid rather than a switch: four rules for notification preferences.
Where this gets harder
Three honest limits, because you will hit them in week two rather than in the demo.
Styling. The components theme through CSS custom properties, with light and
dark handled by prefers-color-scheme. That is enough for most apps.
If your design system is strict about markup, skip the components and use the hooks:
useNotifications, useUnreadCount and
usePreferences give you the same behaviour and none of the
rendering. The components are thin shells over exactly those.
Contact provisioning stays yours. A contact token cannot create a contact, by design, so first-login sync is your backend's job either way. Step two is not a workaround, it is the shape.
Self-hosting has one connection detail. The stream holds an open connection per session, and a proxy or load balancer with a short idle timeout will cut it. The heartbeat goes out every twenty to thirty seconds, so set that timeout comfortably above it.
Email and push are the same call
Add an email template to that same type and the next trigger reaches both, honouring whatever each contact has turned off, with no change to the request you just made. That is the reason the type and the template are separate things in the first place: the event your product emits is stable, and the channels it reaches are not.
Every route in full is in the quickstart, the credential model is in authentication, and the component and hook reference is in React. Vue, Svelte, React Native and Web Components follow the same shape, so the two steps that matter here are the same in all of them.
One API for in-app, email and push
Per-tenant branding, per-contact preferences, and a real-time inbox with no polling, plus a self-host option so the exit stays open. Free tier, no card.