arch-atlas
SaaS Atlas·Pillar 08 / 12

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.

7Nodes6Vendors0Decisions7Checklist1Flows

Primary stack

The default picks this pillar assumes

  • Knock
  • Resend

Alternates worth knowing

Swap in when scale, cost or constraints change

  • Courier
  • Novu
  • Postmark
  • AWS SES
01
OrchestrationOne event → many channels

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
  1. 01

    Domain event → server emits knock.workflows.trigger('<key>', payload).

    step
  2. 02

    Knock loads the workflow definition → checks recipient preferences.

    step
  3. 03

    Per channel: render template (Liquid / React Email) with the payload.

    step
  4. 04

    Knock fires to Resend (email) / FCM (push) / its own in-app feed API.

    step
  5. 05

    Delivery status webhooks → your DB for the per-notification activity log.

    step

Cases & edge cases

watch for these

Use 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 this

Knock

primarysite

Workflow engine for notifications.

Courier

alternate

Similar, more channel breadth (Slack/Teams/Discord deeper).

Novu

alternate

Open-source alternative; self-hostable.

Where it lives

code references
References
  • lib/notifications/knock.ts
  • app/api/webhooks/knock/route.ts // delivery status
02
ChannelsTransactional email

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
  1. 01

    Templates as React components: emails/InviteUser.tsx.

    step
  2. 02

    Triggered by Knock OR directly: resend.emails.send({ from, to, subject, react: <InviteUser ... /> }).

    step
  3. 03

    DKIM + SPF + DMARC configured on your sending domain (1-time setup).

    step
  4. 04

    Bounces + complaints surfaced via webhooks → suppress addresses automatically.

    step

Cases & edge cases

watch for these

Always 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 this

Resend

primarysite

Modern email API + React templates.

pricingFree 3k/mo, then $20/mo for 50k

Postmark

alternate

Older, deliverability gold standard.

AWS SES

alternate

Cheapest at scale; cruder DX.

Where it lives

code references
References
  • emails/ // React Email templates
  • lib/email/send.ts
03
ChannelsNative + web push

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
  1. 01

    Mobile app registers token on launch → backend stores per (user, device).

    step
  2. 02

    Notification trigger → Knock identifies user → fetches device tokens → fires per-platform.

    step
  3. 03

    FCM/APNs deliver → app foreground handler renders banner or routes to silent sync.

    step
  4. 04

    Failed deliveries (token expired) → automatic suppression + cleanup job.

    step

Cases & edge cases

watch for these

iOS 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
References
  • lib/notifications/push-tokens.ts
04
ChannelsBell icon · unread count

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
  1. 01

    Knock <KnockProvider> wraps app; <NotificationFeedPopover> renders the bell.

    step
  2. 02

    Real-time updates via Knock's WebSocket connection.

    step
  3. 03

    Click → opens panel; click an item → marks read + navigates via deep link.

    step
  4. 04

    Server-side: workflow has an 'in-app' channel step → Knock stores message in feed.

    step

Cases & edge cases

watch for these

Real-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
References
  • components/notifications/bell.tsx // <NotificationFeedPopover />
05
PreferencesPer-channel opt-in

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
  1. 01

    Knock exposes per-user preferences API (or hosted preference center).

    step
  2. 02

    App reads + renders as a grid of toggles.

    step
  3. 03

    On change → PATCH preferences via Knock client.

    step
  4. 04

    Workflow checks preferences before each channel step → silently skips disabled channels.

    step

Cases & edge cases

watch for these

Unsubscribe 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
  • Granular per-category preferences

    must
  • Email unsubscribe (token URL, no login)

    must
  • Quiet hours support

    should
  • Org-level mandatory categories

    should
06
ContentReact Email + i18n

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
  1. 01

    Designer / engineer authors emails/InviteUser.tsx with t() calls.

    step
  2. 02

    Translations: locales/en.json, locales/fr.json, etc.

    step
  3. 03

    Render-time: read user locale → pass locale prop → t() resolves.

    step
  4. 04

    Preview server: localhost:3001 mounted dev UI for previewing every template in every locale.

    step

Cases & edge cases

watch for these

Always 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
References
  • emails/
  • scripts/preview-emails.ts
07
OrchestrationDon't be a spammer

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
  1. 01

    Workflow step 'batch': window=30min, key=user_id+category.

    step
  2. 02

    Events arriving in the window accumulate into a single batched payload.

    step
  3. 03

    Window closes → template renders the aggregated list ('5 new comments').

    step
  4. 04

    User clicks → app shows the unified inbox view.

    step

Cases & edge cases

watch for these

Batching 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
  • Batch window configured for chatty categories

    should
  • Daily / weekly digest workflow available

    should
  • Empty-digest suppression

    should
Annotate0 strokes

Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.