Notifications
Knock · Resend · FCM/APNs · In-app inbox
Notifications look easy until you ship them: 'send an email' becomes 'send an email respecting the user's quiet hours, the org's plan, the digest preference, the locale, the unsubscribe state'. A dedicated notification platform (Knock) handles templates + preferences + batching as a service so you focus on emitting events. Channels (Resend for email, FCM for push) become exchangeable downstream.
Primary stack
The default picks this pillar assumes
- Knock
- ResendFree 3k/mo, then $20/mo for…
Alternates worth knowing
Swap in when scale, cost or constraints change
- Courier
- Novu
- Postmark
- AWS SES
Knock Orchestration
Knock is a workflow engine for notifications: you POST `knock.workflows.trigger('comment.created', { recipients, data })`, Knock fans out to email + push + in-app + Slack according to per-user preferences and the workflow steps you authored in their dashboard.
How it flows
step by step- 01step
Domain event → server emits knock.workflows.trigger('<key>', payload).
- 02step
Knock loads the workflow definition → checks recipient preferences.
- 03step
Per channel: render template (Liquid / React Email) with the payload.
- 04step
Knock fires to Resend (email) / FCM (push) / its own in-app feed API.
- 05step
Delivery status webhooks → your DB for the per-notification activity log.
Cases & edge cases
watch for theseUse stable workflow keys — they're your contract. Renaming = migration.
Identifying users to Knock: send your stable user_id; sync display name + email + locale on user.updated.
Workflows can have delays, conditions, batching — express these in Knock, not in app code.
Vendors & tools
what implements thisWorkflow engine for notifications.
Courier
alternateSimilar, more channel breadth (Slack/Teams/Discord deeper).
Novu
alternateOpen-source alternative; self-hostable.
Where it lives
code references- lib/notifications/knock.ts
- app/api/webhooks/knock/route.ts // delivery status
Resend (Email)
Resend is the modern transactional email API. React Email for templates (you write JSX, get pixel-perfect HTML), great DX, generous free tier. Plug into Knock as the email provider.
How it flows
step by step- 01step
Templates as React components: emails/InviteUser.tsx.
- 02step
Triggered by Knock OR directly: resend.emails.send({ from, to, subject, react: <InviteUser ... /> }).
- 03step
DKIM + SPF + DMARC configured on your sending domain (1-time setup).
- 04step
Bounces + complaints surfaced via webhooks → suppress addresses automatically.
Cases & edge cases
watch for theseAlways have a dedicated sending domain (mail.yourapp.com) — keeps the marketing domain reputation safe.
Don't send marketing email from the transactional API — use a separate tool (Loops, Customer.io) and a separate domain.
Plain-text fallback matters for accessibility and old clients — React Email generates it for you.
Vendors & tools
what implements thisPostmark
alternateOlder, deliverability gold standard.
AWS SES
alternateCheapest at scale; cruder DX.
Where it lives
code references- emails/ // React Email templates
- lib/email/send.ts
Push (FCM / APNs)
Firebase Cloud Messaging handles both iOS (via APNs) and Android push. Web push via VAPID for browser notifications. Knock dispatches; FCM delivers; your mobile app receives via the FCM SDK.
How it flows
step by step- 01step
Mobile app registers token on launch → backend stores per (user, device).
- 02step
Notification trigger → Knock identifies user → fetches device tokens → fires per-platform.
- 03step
FCM/APNs deliver → app foreground handler renders banner or routes to silent sync.
- 04step
Failed deliveries (token expired) → automatic suppression + cleanup job.
Cases & edge cases
watch for theseiOS notification permission UX: ask at a contextual moment (after first valuable interaction), not on first launch.
Web push needs HTTPS + service worker + VAPID keys; supported in Chrome/Firefox/Safari 16+.
Notification grouping (thread_id on iOS, channel on Android) prevents 'spam from one app' feeling.
Where it lives
code references- lib/notifications/push-tokens.ts
In-App Inbox
The bell icon in the app shell with a list of recent notifications, unread badge, mark-as-read. Knock provides @knocklabs/react drop-in components; you can also self-host with a notifications table you own.
How it flows
step by step- 01step
Knock <KnockProvider> wraps app; <NotificationFeedPopover> renders the bell.
- 02step
Real-time updates via Knock's WebSocket connection.
- 03step
Click → opens panel; click an item → marks read + navigates via deep link.
- 04step
Server-side: workflow has an 'in-app' channel step → Knock stores message in feed.
Cases & edge cases
watch for theseReal-time WebSocket fallbacks to polling — handle gracefully in flaky networks.
Mark-as-read on click and on 'panel open' (debounced) — match user mental model.
Pagination on the feed; infinite scroll past 50 items.
Where it lives
code references- components/notifications/bell.tsx // <NotificationFeedPopover />
User Preferences
Settings page where users toggle which channels they want per category: 'Comments → email yes, push no, in-app yes'. Knock manages the preference matrix; your UI is a wrapper.
How it flows
step by step- 01step
Knock exposes per-user preferences API (or hosted preference center).
- 02step
App reads + renders as a grid of toggles.
- 03step
On change → PATCH preferences via Knock client.
- 04step
Workflow checks preferences before each channel step → silently skips disabled channels.
Cases & edge cases
watch for theseUnsubscribe link in every email MUST work without login (signed token in URL).
Quiet hours / DND windows per timezone — Knock supports this natively.
Org-level overrides: admin can mandate certain notifications (security alerts) regardless of user prefs.
Checklist
ship readiness- must
Granular per-category preferences
- must
Email unsubscribe (token URL, no login)
- should
Quiet hours support
- should
Org-level mandatory categories
Templates & Localization
Templates live as React components (emails/) for email and Liquid in Knock for push/in-app. Localized via next-intl for web, intl package for Flutter, with shared translation keys. One template = many languages.
How it flows
step by step- 01step
Designer / engineer authors emails/InviteUser.tsx with t() calls.
- 02step
Translations: locales/en.json, locales/fr.json, etc.
- 03step
Render-time: read user locale → pass locale prop → t() resolves.
- 04step
Preview server: localhost:3001 mounted dev UI for previewing every template in every locale.
Cases & edge cases
watch for theseAlways have a preview tool — debugging an email in production is the worst kind of debugging.
Plain-text version generated automatically (React Email) but verify links survive HTML stripping.
Localize the unsubscribe link too — 'unsubscribe' in English looks wrong in a French email.
Where it lives
code references- emails/
- scripts/preview-emails.ts
Digests & Batching
If 12 things happen in 5 minutes, send ONE notification, not 12. Knock workflows can batch by window (e.g. 'wait 30 min, then send'). Daily / weekly digests for non-urgent categories.
How it flows
step by step- 01step
Workflow step 'batch': window=30min, key=user_id+category.
- 02step
Events arriving in the window accumulate into a single batched payload.
- 03step
Window closes → template renders the aggregated list ('5 new comments').
- 04step
User clicks → app shows the unified inbox view.
Cases & edge cases
watch for theseBatching is per-category, not global — 'security alerts' should never batch.
Daily digest scheduled in user's timezone — Knock handles this with users' tz on record.
Empty digest? Don't send. Suppress when the batch resolves to zero items.
Checklist
ship readiness- should
Batch window configured for chatty categories
- should
Daily / weekly digest workflow available
- should
Empty-digest suppression
Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.