Replace parts of the inbox
Render slots are web SDKs only (React, Vue, Angular, Web Components). On iOS, Android, Flutter, and React Native, use theming or build your own UI below.
header, listItem, emptyState, loadingState, errorState, or paginationItem. Each renderer receives the relevant message or state. Slots you leave out use the built-in element.

An inbox with a custom header and custom list items, while the SDK still manages the list.
<CourierInbox
renderListItem={(props) => <MyRow message={props?.message} />}
renderHeader={(props) => <MyHeader feeds={props?.feeds} />}
renderEmptyState={() => <MyEmptyState />}
/>
const inbox = document.getElementById("inbox");
// Each factory returns an HTMLElement
inbox.setListItem((props) => renderRow(props));
inbox.setHeader((props) => renderHeader(props));
inbox.setEmptyState(() => renderEmpty());
<template>
<CourierInbox
:renderListItem="(props) => renderRow(props)"
:renderHeader="(props) => renderHeader(props)"
/>
</template>
<courier-inbox>
<ng-template #listItem let-props>
<!-- your custom row; props.message is available -->
</ng-template>
<ng-template #header let-props>
<!-- your custom header -->
</ng-template>
</courier-inbox>
| Slot | React / Vue prop | Web component setter | Angular template |
|---|---|---|---|
| Header | renderHeader | setHeader | #header |
| List item | renderListItem | setListItem | #listItem |
| Empty state | renderEmptyState | setEmptyState | #emptyState |
| Loading state | renderLoadingState | setLoadingState | #loadingState |
| Error state | renderErrorState | setErrorState | #errorState |
| Pagination item | renderPaginationItem | setPaginationItem | #paginationItem |
Build your own UI
You do not have to renderCourierInbox at all. Every SDK exposes the message data and actions directly, so you can build the whole UI while the SDK keeps everything synced. The setup is the same everywhere:
- Start the feed.
- Render your own UI from the messages.
- Toggle read (or archive) on interaction.
- Tear down the listener when the view goes away.
- Web SDKs: nothing loads automatically. Register the feeds, open the realtime connection, then load:
registerFeeds(defaultFeeds())→listenForUpdates()→load(). - Mobile SDKs: adding an inbox listener loads the first page for you, if you have first. (
refreshInbox()is only for pull-to-refresh.)
import { useEffect } from "react";
import { useCourier, defaultFeeds } from "@trycourier/courier-react"; // or "@trycourier/courier-react-17"
function CustomInbox() {
const { inbox } = useCourier();
// 1. Start the feed (nothing loads automatically; the hook cleans up on unmount)
useEffect(() => {
inbox.registerFeeds(defaultFeeds());
inbox.listenForUpdates();
inbox.load();
}, []);
// 2. Render the default feed's messages
const messages = inbox.feeds["all_messages"]?.messages ?? [];
return (
<ul>
{messages.map((message) => (
// 3. Toggle read on tap
<li
key={message.messageId}
onClick={() => (message.read ? inbox.unreadMessage(message) : inbox.readMessage(message))}
>
{message.title}
</li>
))}
</ul>
);
}
import {
CourierInboxDatastore,
CourierInboxDataStoreListener,
defaultFeeds,
markAsRead,
markAsUnread,
} from "@trycourier/courier-ui-inbox";
const store = CourierInboxDatastore.shared;
// 1. Subscribe, then start the feed (nothing loads automatically)
const listener = new CourierInboxDataStoreListener({
onDataSetChange: (dataset) => render(dataset.messages), // 2. Build your own UI
});
store.addDataStoreListener(listener);
store.registerFeeds(defaultFeeds());
await store.listenForUpdates();
await store.load();
// 3. Toggle read on tap
message.read ? markAsUnread(message) : markAsRead(message);
// 4. Tear down
store.removeDataStoreListener(listener);
<script setup lang="ts">
import { onMounted } from "vue";
import { useCourier, defaultFeeds } from "@trycourier/courier-vue";
const { inbox } = useCourier(); // inbox is a ref
// 1. Start the feed (nothing loads automatically; the composable cleans up on unmount)
onMounted(() => {
inbox.value.registerFeeds(defaultFeeds());
inbox.value.listenForUpdates();
inbox.value.load();
});
// 3. Toggle read on tap
function toggle(message) {
message.read ? inbox.value.unreadMessage(message) : inbox.value.readMessage(message);
}
</script>
<template>
<!-- 2. Render the default feed's messages -->
<ul>
<li
v-for="message in inbox.feeds['all_messages']?.messages ?? []"
:key="message.messageId"
@click="toggle(message)"
>
{{ message.title }}
</li>
</ul>
</template>
import { Component, inject, OnInit, OnDestroy } from "@angular/core";
import { CourierService, defaultFeeds, type InboxMessage } from "@trycourier/courier-angular";
@Component({ /* ... */ })
export class CustomInbox implements OnInit, OnDestroy {
private readonly courier = inject(CourierService);
messages: InboxMessage[] = [];
private sub?: { unsubscribe(): void };
async ngOnInit() {
// 2. Render: inbox$ emits the reactive state
this.sub = this.courier.inbox$.subscribe(
(state) => (this.messages = state.feeds["all_messages"]?.messages ?? [])
);
// 1. Start the feed (nothing loads automatically)
this.courier.registerFeeds(defaultFeeds());
await this.courier.listenForUpdates();
await this.courier.load();
}
// 3. Toggle read on tap
toggle(message: InboxMessage) {
message.read ? this.courier.unreadMessage(message) : this.courier.readMessage(message);
}
// 4. Tear down
ngOnDestroy() {
this.sub?.unsubscribe();
}
}
// 1. Start: adding a listener loads the first page (sign in first)
let listener = await Courier.shared.addInboxListener(
onMessagesChanged: { messages, canPaginate, feed in
render(messages) // 2. Build your own UI
}
)
// 3. Toggle read on tap
if message.isRead {
try await message.markAsUnread()
} else {
try await message.markAsRead()
}
// 4. Tear down
listener.remove()
// 1. Start: adding a listener loads the first page (sign in first).
// addInboxListener is a suspend function; call it from a coroutine.
lifecycleScope.launch {
val listener = Courier.shared.addInboxListener(
onMessagesChanged = { messages, canPaginate, feed ->
render(messages) // 2. Build your own UI
}
)
// 4. Tear down when the view goes away: listener.remove()
}
// 3. Toggle read on tap
if (message.isRead) message.markAsUnread() else message.markAsRead()
// 1. Start: adding a listener loads the first page (sign in first)
final listener = await Courier.shared.addInboxListener(
onMessagesChanged: (messages, canPaginate, feed) {
render(messages); // 2. Build your own UI
},
);
// 3. Toggle read on tap
message.isRead ? await message.markAsUnread() : await message.markAsRead();
// 4. Tear down
await listener.remove();
// 1. Start: adding a listener loads the first page (sign in first)
const listener = await Courier.shared.addInboxListener({
onMessagesChanged: (messages, canPaginate, feed) => {
render(messages); // 2. Build your own UI
},
});
// 3. Toggle read on tap
message.isRead
? await Courier.shared.unreadMessage({ messageId: message.messageId })
: await Courier.shared.readMessage({ messageId: message.messageId });
// 4. Tear down
await listener.remove();
useCourier hook) also reports loading, errors, unread count, and pagination state. See Data and actions for the full surface. On the mobile SDKs and Web Components, remove the listener when your view is torn down. The React, Vue, and Angular wrappers do this for you.
Data and actions
The pattern above uses a subset of what each SDK exposes. Here is the full surface: the data you read (messages, unread count, pagination state) and the actions you call (mark read, archive, paginate, refresh). The built-in inbox calls these for you. On the web, read the data from theuseCourier hook (React and Vue), the CourierInboxDatastore (Web Components), or the CourierService (Angular). On mobile, an inbox listener pushes the data to you and the actions take a messageId.
const { inbox } = useCourier();
// DATA (values you read)
// All feeds, keyed by datasetId (each: messages + pagination state)
inbox.feeds;
// Total unread across every feed
inbox.totalUnreadCount;
// Last error, if any
inbox.error;
// Each InboxMessage: messageId, title, body, preview, actions, data,
// created, read, opened, archived, tags
// ACTIONS (functions you call)
// Mark read / unread
inbox.readMessage(message);
inbox.unreadMessage(message);
// Track click / open
inbox.clickMessage(message);
inbox.openMessage(message);
// Archive / unarchive
inbox.archiveMessage(message);
inbox.unarchiveMessage(message);
// Mark every message read
inbox.readAllMessages();
// Set the page size
inbox.setPaginationLimit(50);
// Load the next page of a feed
inbox.fetchNextPageOfMessages({ datasetId });
// Define which feeds/tabs to load
inbox.registerFeeds(feeds);
// (Re)load the feeds
inbox.load({ canUseCache: true });
// Start the realtime connection
inbox.listenForUpdates();
import { Courier, CourierInboxDatastore } from "@trycourier/courier-ui-inbox";
const store = CourierInboxDatastore.shared;
// DATA (values you read)
// All feeds, keyed by datasetId
store.getDatasets();
// One feed's dataset
store.getDatasetById(datasetId);
// Total unread across every feed
store.totalUnreadCount;
// React to changes
store.addDataStoreListener(listener);
// Stop reacting (cleanup)
store.removeDataStoreListener(listener);
// ACTIONS (functions you call)
// Mark read / unread
store.readMessage({ message });
store.unreadMessage({ message });
// Track click / open
store.clickMessage({ message });
store.openMessage({ message });
// Archive / unarchive
store.archiveMessage({ message });
store.unarchiveMessage({ message });
// Bulk: read all, archive all, archive read
store.readAllMessages();
store.archiveAllMessages();
store.archiveReadMessages();
// Inject a message into the store
store.addMessage(message);
// Load the next page of a feed
store.fetchNextPageOfMessages({ datasetId });
// Define which feeds/tabs to load
store.registerFeeds(feeds);
// (Re)load the feeds
store.load({ canUseCache: true });
// Badge counts without loading messages
store.loadUnreadCountsForTabs(tabIds);
// Start the realtime connection
store.listenForUpdates();
// Set the page size
Courier.shared.paginationLimit = 50;
const { inbox } = useCourier();
const store = inbox.value; // inbox is a ref
// DATA (values you read)
// All feeds, keyed by datasetId (each: messages + pagination state)
store.feeds;
// Total unread across every feed
store.totalUnreadCount;
// Last error, if any
store.error;
// ACTIONS (functions you call)
// Mark read / unread
store.readMessage(message);
store.unreadMessage(message);
// Track click / open
store.clickMessage(message);
store.openMessage(message);
// Archive / unarchive
store.archiveMessage(message);
store.unarchiveMessage(message);
// Mark every message read
store.readAllMessages();
// Set the page size
store.setPaginationLimit(50);
// Load the next page of a feed
store.fetchNextPageOfMessages({ datasetId });
// Define which feeds/tabs to load
store.registerFeeds(feeds);
// (Re)load the feeds
store.load({ canUseCache: true });
// Start the realtime connection
store.listenForUpdates();
import { inject } from "@angular/core";
import { CourierService } from "@trycourier/courier-angular";
const courier = inject(CourierService);
// DATA: subscribe to inbox$ (CourierInboxState)
courier.inbox$.subscribe((state) => {
// All feeds, keyed by datasetId
state.feeds;
// Total unread across every feed
state.totalUnreadCount;
// Last error, if any
state.error;
});
// ACTIONS (take a bare message, not { message })
// Mark read / unread
courier.readMessage(message);
courier.unreadMessage(message);
// Track click / open
courier.clickMessage(message);
courier.openMessage(message);
// Archive / unarchive
courier.archiveMessage(message);
courier.unarchiveMessage(message);
// Mark every message read
courier.readAllMessages();
// Set the page size
courier.setPaginationLimit(50);
// Load the next page of a feed
courier.fetchNextPageOfMessages({ datasetId });
// Define which feeds/tabs to load
courier.registerFeeds(feeds);
// (Re)load the feeds
courier.load({ canUseCache: true });
// Start the realtime connection
courier.listenForUpdates();
// For the full datastore surface (bulk archive, direct dataset reads), use
// CourierInboxDatastore.shared from "@trycourier/courier-ui-inbox":
// store.archiveAllMessages(); store.archiveReadMessages();
// store.getDatasets(); store.getDatasetById(datasetId); store.totalUnreadCount;
// Subscribe for live data
let listener = await Courier.shared.addInboxListener(
onLoading: { isRefresh in /* spinner */ },
onError: { error in /* handle */ },
onUnreadCountChanged: { count in /* badge */ },
onTotalCountChanged: { totalCount, feed in /* count */ },
onMessagesChanged: { messages, canPaginate, feed in /* render */ },
onPageAdded: { messages, canPaginate, isFirstPage, feed in /* append */ },
onMessageEvent: { message, index, feed, event in /* .added / .read / .archived ... */ }
)
// DATA (values you read)
// Messages in the main feed
Courier.shared.feedMessages // [InboxMessage]
// Messages in the archived feed
Courier.shared.archivedMessages // [InboxMessage]
// Current page size
Courier.shared.inboxPaginationLimit // Int
// Each InboxMessage: messageId, title, body, preview, subtitle,
// isRead, isOpened, isArchived, created, actions
// ACTIONS (async throws; take a messageId)
// Mark read / unread
try await Courier.shared.readMessage(messageId)
try await Courier.shared.unreadMessage(messageId)
// Track click / open
try await Courier.shared.clickMessage(messageId)
try await Courier.shared.openMessage(messageId)
// Archive (no unarchive on iOS)
try await Courier.shared.archiveMessage(messageId)
// Mark every message read
try await Courier.shared.readAllInboxMessages()
// Set the page size
Courier.shared.setPaginationLimit(50)
// Load the next page (.feed or .archive)
try await Courier.shared.fetchNextInboxPage(.feed)
// Refresh from the server
await Courier.shared.refreshInbox()
// Stop listening (cleanup)
listener.remove()
// Or from a message instance:
// markAsRead() / markAsUnread() / markAsOpened() / markAsClicked() / markAsArchived()
// Subscribe for live data (addInboxListener is a suspend function)
lifecycleScope.launch {
val listener = Courier.shared.addInboxListener(
onLoading = { isRefresh -> /* spinner */ },
onError = { error -> /* handle */ },
onUnreadCountChanged = { count -> /* badge */ },
onTotalCountChanged = { totalCount, feed -> /* count */ },
onMessagesChanged = { messages, canPaginate, feed -> /* render */ },
onPageAdded = { messages, canPaginate, isFirstPage, feed -> /* append */ },
onMessageEvent = { message, index, feed, event -> /* InboxMessageEvent.ADDED / .READ ... */ }
)
}
// DATA (values you read)
// Messages in the main feed
Courier.shared.feedMessages // List<InboxMessage>
// Messages in the archived feed
Courier.shared.archivedMessages // List<InboxMessage>
// Current page size (1..100)
Courier.shared.inboxPaginationLimit
// Each InboxMessage: messageId, title, body, preview, subtitle,
// isRead, isOpened, isArchived, created, actions
// ACTIONS (suspend; take a messageId)
// Mark read / unread
Courier.shared.readMessage(messageId)
Courier.shared.unreadMessage(messageId)
// Track click / open
Courier.shared.clickMessage(messageId)
Courier.shared.openMessage(messageId)
// Archive (no unarchive on Android)
Courier.shared.archiveMessage(messageId)
// Mark every message read
Courier.shared.readAllInboxMessages()
// Set the page size
Courier.shared.inboxPaginationLimit = 50
// Load the next page (.FEED or .ARCHIVE)
Courier.shared.fetchNextInboxPage(InboxMessageFeed.FEED)
// Refresh from the server
Courier.shared.refreshInbox()
// Tear down and reconnect
Courier.shared.restartInbox()
// Stop the connection
Courier.shared.closeInbox()
// Stop listening (cleanup)
listener.remove()
// Or from a message:
// markAsRead() / markAsUnread() / markAsOpened() / markAsClicked() / markAsArchived()
// Or from one of its actions:
// message.actions?.firstOrNull()?.markAsClicked()
// Subscribe for live data
final listener = await Courier.shared.addInboxListener(
onLoading: (isRefresh) { /* spinner */ },
onError: (error) { /* handle */ },
onUnreadCountChanged: (count) { /* badge */ },
onTotalCountChanged: (feed, totalCount) { /* count (note: feed first) */ },
onMessagesChanged: (messages, canPaginate, feed) { /* render */ },
onPageAdded: (messages, canPaginate, isFirstPage, feed) { /* append */ },
onMessageEvent: (message, index, feed, event) { /* InboxMessageEvent.added / .read ... */ },
);
// DATA (Futures you await)
// Messages in the main feed
await Courier.shared.feedMessages; // List<InboxMessage>
// Messages in the archived feed
await Courier.shared.archivedMessages; // List<InboxMessage>
// Current page size (default 32)
await Courier.shared.inboxPaginationLimit;
// Each InboxMessage: messageId, title, body, preview, subtitle,
// isRead, isOpened, isArchived, created, actions
// ACTIONS (async; take a messageId)
// Mark read / unread
await Courier.shared.readMessage(messageId: id);
await Courier.shared.unreadMessage(messageId: id);
// Track click / open
await Courier.shared.clickMessage(messageId: id);
await Courier.shared.openMessage(messageId: id);
// Archive (no unarchive on Flutter)
await Courier.shared.archiveMessage(messageId: id);
// Mark every message read
await Courier.shared.readAllInboxMessages();
// Set the page size
await Courier.shared.setInboxPaginationLimit(limit: 50);
// Load the next page (InboxFeed.feed or InboxFeed.archive)
await Courier.shared.fetchNextInboxPage(feed: InboxFeed.feed);
// Refresh from the server
await Courier.shared.refreshInbox();
// Stop listening (cleanup)
await listener.remove();
// Or from a message:
// await message.markAsRead() / markAsUnread() / markAsOpened() / markAsClicked() / markAsArchived()
// Subscribe for live data
const listener = await Courier.shared.addInboxListener({
onLoading: (isRefresh) => { /* spinner */ },
onError: (error) => { /* handle */ },
onUnreadCountChanged: (count) => { /* badge */ },
onTotalCountChanged: (totalCount, feed) => { /* count */ },
onMessagesChanged: (messages, canPaginate, feed) => { /* render */ },
onPageAdded: (messages, canPaginate, isFirstPage, feed) => { /* append */ },
onMessageEvent: (message, index, feed, event) => { /* "added" | "read" | "archived" ... */ },
});
// DATA (values you read)
// Current page size (default 32)
await Courier.shared.getInboxPaginationLimit();
// Feed messages arrive via the listener's onMessagesChanged / onPageAdded
// Each InboxMessage: messageId, title, body, preview, subtitle,
// isRead, isOpened, isArchived, created, actions
// ACTIONS (async; take { messageId })
// Mark read / unread
await Courier.shared.readMessage({ messageId });
await Courier.shared.unreadMessage({ messageId });
// Track click / open
await Courier.shared.clickMessage({ messageId });
await Courier.shared.openMessage({ messageId });
// Archive (no unarchive on React Native)
await Courier.shared.archiveMessage({ messageId });
// Mark every message read
await Courier.shared.readAllInboxMessages();
// Set the page size
await Courier.shared.setInboxPaginationLimit(50);
// Load the next page ("feed" or "archive")
await Courier.shared.fetchNextPageOfMessages({ inboxMessageFeed: "feed" });
// Refresh from the server
await Courier.shared.refreshInbox();
// Stop listening (cleanup)
await listener.remove();
// Or from a message:
// await message.markAsRead() / markAsUnread() / markAsOpened() / markAsClicked() / markAsArchived()