In-App Status Banners & System Notifications: Chat Prompts
There's a difference between your public status page (where people check during outages — "Is X down?") and in-app status banners (in-product surfaces telling current users right now: a payment is failing, a feature is degraded, planned maintenance is tomorrow, you're approaching a usage limit, your trial expires in 3 days). Both matter. They're different surfaces, different UX, different audiences.
Most apps build a hardcoded "Subscription expired" banner first, then add a "We're currently experiencing issues with email delivery" banner during an outage, then realize three months later they've shipped six banners with inconsistent designs, no read tracking, no priority order, and no admin UI to publish a new one without a deploy.
This is the chat-prompt playbook for a unified in-app banner / system-notification surface that handles all of these consistently — degradation alerts, planned maintenance, billing nudges, usage warnings, regulatory disclosures, regional outage messaging — with a clean priority model, dismissal tracking, and an admin UI so non-engineers can publish.
When You Need a Banner (and Not a Toast / Modal / Email)
Use banners for:
- Persistent, account-relevant state that affects what the user can / will do (your card failed; trial expires in 3 days; feature X is degraded)
- Time-sensitive but not blocking (planned maintenance Friday)
- Cross-session: even if user signs out + back in, they should see it
- Status the user can ignore today, but probably shouldn't ignore for a week
Use toasts for: ephemeral feedback after an action (saved, error, copied)
Use modals for: truly blocking — must acknowledge to continue (e.g., new ToS requires acceptance)
Use email for: out-of-product reach (account issues that affect users not currently in the app)
Use the public status page for: non-customers, customers checking from outside the app, post-incident transparency
A well-designed in-app system shows the right banner, in the right priority order, dismissible if appropriate, persistent if not. It's not noise; it's deliberate communication.
Banner Categories
Different categories need different policies:
| Category | Example | Priority | Dismissible? | Auto-resolve? |
|---|---|---|---|---|
| Critical (account-action required) | Payment failed; will close in 3 days | 1 | No (until resolved) | Yes (on payment) |
| Service degradation | Email delivery is delayed | 2 | Yes | Yes (when fixed) |
| Service outage (subset) | Search is currently unavailable | 1 | No | Yes |
| Planned maintenance | Sunday 2-4am UTC scheduled downtime | 3 | Yes | Yes (after time) |
| Billing nudge | Trial ends in 3 days | 4 | Yes (re-shown periodically) | Yes (on upgrade) |
| Usage warning | At 80% of plan limit | 4 | Yes (re-shown at higher thresholds) | Yes (on upgrade or new period) |
| Feature announcement | New: AI Insights live | 5 | Yes (one-time) | n/a |
| Regulatory / legal | Updated Privacy Policy; review by [date] | 2 | After acknowledging | Yes (on acknowledge) |
| Region-specific | EU users see GDPR disclosure | 3 | Yes | n/a |
| Customer-specific (account-level) | Your custom domain has SSL expired | 1 | No | Yes (on renewal) |
Lower priority number = higher priority. Show one or two banners at most; queue the rest.
Data Model
I'm building a unified system-notification banner system for my B2B SaaS.
Schema (Drizzle):
- `system_banners` table:
- id
- title (string, max 100 chars)
- body (markdown, max 500 chars)
- category (enum: critical, degradation, outage, maintenance, billing_nudge, usage_warning, announcement, regulatory, region, account)
- priority (1-5; 1 = most important)
- cta_label (string, optional)
- cta_url (string, optional)
- dismissible (boolean)
- segments (JSON array: which users see this — by plan, role, region, account_id)
- starts_at (timestamp)
- ends_at (timestamp, nullable — null = until manually ended)
- status (enum: draft, scheduled, active, ended)
- created_by_admin_id (FK)
- acknowledgment_required (boolean)
- `banner_dismissals` join table:
- banner_id, user_id, dismissed_at
- One row per user per banner they've dismissed
- `banner_acknowledgments` join table:
- banner_id, user_id, acknowledged_at
- For regulatory / legal banners that need explicit ack
Implement:
1. The schema
2. A function `getActiveBannersForUser(userId)`:
- Filter active (status='active', starts_at <= now <= ends_at)
- Filter by user segment match
- Exclude banners user has dismissed (if dismissible)
- Exclude banners user has acknowledged (if acknowledgment-only)
- Sort by priority ascending
- Return top 1-2 banners
3. Server action: `dismissBanner(bannerId, userId)` and `acknowledgeBanner(bannerId, userId)`
Stack: Next.js App Router + Drizzle + Postgres.
Banner Component
Build the `<SystemBanner />` component:
Layout:
- Full-width bar at the top of the app (above the sidebar/main area; below any global header)
- Icon left (matches category)
- Title bold + body text
- CTA button right (if cta_url provided)
- Dismiss × button right (if dismissible)
- Color: derived from category (critical=red, degradation=yellow, maintenance=blue, billing=orange, announcement=neutral)
Behavior:
- On dismiss: optimistically remove + call server action `dismissBanner`
- On CTA click: navigate / open / open external
- If acknowledgment_required: dismiss button replaced with "I understand" button that triggers `acknowledgeBanner` and may open a modal with full text
Accessibility:
- role="status" or role="alert" depending on priority (alert for critical/outage; status for everything else)
- ARIA-live "polite" or "assertive" matching role
- Dismiss button has accessible label
- Color is not the only signal (icons + text)
Multi-banner queue:
- If multiple active banners, render top 1 (or 2 max)
- A "More notifications (3)" link at the right opens a panel with all active banners
Stack: Next.js + Tailwind + shadcn/ui Alert as base + Radix.
Where to Render
Where in my Next.js App Router layout should the system banner render?
Considerations:
- Should appear on every authenticated page
- Should NOT appear on auth pages (sign in / sign up / password reset)
- Should NOT appear in the email-sent / post-checkout / post-action confirmation pages where it would distract
- Sticky at the top, above the main app content but below the global nav
- Mobile: same logic; banner is full-width
Implement:
- Render in `app/(app)/layout.tsx` (the authenticated app layout)
- Pass user-specific banners as props from a server component that calls `getActiveBannersForUser`
- Keep it Server Component for fast initial render; the dismiss interaction is via Server Action
Stack: Next.js 16 App Router + Server Components + Server Actions.
Admin UI for Publishing Banners
Engineers shouldn't deploy code to publish "we have an email delivery issue."
Build the admin UI for managing system banners:
1. /admin/banners — list view
- Columns: title, category, priority, segments, status, starts_at, ends_at, audience size, dismiss rate
- Filter by status (active, draft, scheduled, ended)
- Quick "End now" button for any active banner
- "New banner" CTA
2. /admin/banners/new — create / edit
- Fields: title, body (markdown editor), category (auto-suggests priority), CTA label + url, dismissible (default per category), segments (multi-select: plan tier, role, region, specific accounts), starts_at, ends_at, acknowledgment_required (default per category)
- Preview pane showing how it'll render in light + dark mode
- "Send to test segment" — show only to admins for verification before going live
- Validate: title ≤ 100; body ≤ 500; future starts_at > now; ends_at > starts_at
3. Authorization:
- Only admins with `system_admin` role
- Audit log entry on every banner create / update / publish / end
4. Banner analytics per banner:
- Total reach (users in segment matched while banner active)
- View rate (users who landed on a page where it rendered)
- Dismiss rate (% of viewers who dismissed)
- CTA click-through rate
- Acknowledgment rate (if required)
Stack: Next.js + Drizzle + shadcn/ui forms + a markdown editor (TipTap or simpler textarea+react-markdown preview).
Targeting / Segments
Add segment targeting to banners:
Segment types:
- "all" (everyone authenticated)
- "plan:[tier]" (users on a specific plan: free / pro / enterprise)
- "role:[role]" (admin / member / billing-owner)
- "region:[region]" (EU / US / etc — derived from user.country or user.timezone)
- "account_id:[id]" (account-specific banner — useful for "your custom domain SSL expired" cases)
- "feature_flag:[flag]" (only users in a specific GrowthBook / LaunchDarkly cohort)
- "trial_status:[expiring|expired|active]" (computed from user record)
- "billing_status:[hold|active|overdue]" (computed from billing system)
Implement segment-matching as a function:
```ts
function userMatchesSegment(user: User, segment: string): boolean
Each segment evaluates against the user object. Banner shows if user matches at least one segment in the banner.segments array.
Stack: Next.js + your existing auth + billing system.
## Polling / Live Updates
A new banner published mid-session should show up to current users without requiring a refresh.
Make banner state live for active sessions:
Options:
- Poll
/api/bannersevery 60 seconds — simple, works everywhere - Server-Sent Events (SSE) channel — push to clients on banner change
- WebSocket — overkill for this use case
Pick option 1 (polling) for simplicity. Implementation:
- TanStack Query with
refetchInterval: 60_000andrefetchOnWindowFocus: true - The query key includes user ID so different users get different banners
- On user dismissal, optimistically update the cache; the server action confirms
Edge: don't poll if window is hidden (browsers throttle anyway, but TanStack respects refetchIntervalInBackground: false).
Stack: Next.js + TanStack Query.
## Acknowledgments (Regulatory / ToS Updates)
Some banners need an explicit "I understand" — not just a dismiss.
Build the acknowledgment flow:
Use case: ToS updated; users must read + acknowledge before continuing.
Behavior:
- Banner appears at top with a "Review and Acknowledge" CTA
- Click opens a full-screen modal with the full text (markdown rendered)
- User scrolls to bottom (track scroll progress)
- "I have read and agree" button enabled only after scrolling to ≥90%
- Click button: server action records acknowledgment with timestamp
- Modal closes; banner disappears
Stronger variant: until acknowledged, the rest of the app is read-only. The banner is non-dismissible. Use for legally-required acknowledgments only — annoying for soft updates.
Implement:
- The modal component with scroll tracking
- The acknowledgment server action
- The "lockdown" mode for required acknowledgments
Stack: Next.js + shadcn/ui Dialog + Tailwind.
## Outage / Degradation Auto-Banner
When your status page detects an incident, automatically show the in-app banner.
Auto-publish a banner when our public status page (Statuspage / Better Stack / Instatus) detects an incident:
Pattern:
- Status page provider has webhooks (Statuspage's
incident.createevent) - Endpoint receives webhook → creates a
system_bannerrow with:- category: degradation or outage (based on severity)
- title: incident title from status page
- body: incident description
- cta: link to status page
- segments: 'all' (or filter by affected component if mapping exists)
- starts_at: now
- ends_at: null (will be ended when incident resolves)
- On
incident.resolvewebhook: end the corresponding banner
Implement:
- The webhook handler in
app/api/webhooks/statuspage/route.ts - HMAC signature verification for the webhook
- Mapping of status page components → user segments (e.g., "EU region" component → segment "region:EU")
- Idempotent: if the same incident is fired twice, don't create two banners
Stack: Next.js + Drizzle + Statuspage / Better Stack webhooks.
## Common Pitfalls
**No priority queue.** When 3 banners are active, the user sees one of them randomly (or all of them stacked). Show the top one or two by priority.
**Banner spam.** Marketing + Engineering + Customer Success all publish banners; the user sees 5 in a row. Centralize publishing approval; require category + priority discipline.
**Dismissed banners that never come back.** A user dismisses "Trial ending in 5 days." Trial is now 1 day from ending. Banner should re-emerge at higher urgency thresholds.
**No read state.** Banner shows every page load forever for the same user. Track dismissal per user.
**Banner survives the issue.** Email delivery was fixed an hour ago; banner still shows "email is degraded." Always set `ends_at` or end the banner programmatically when the underlying issue resolves.
**Banner content not localized.** EN-only banner shown to JP / DE users. If you support multiple languages, banner content must too — or fall back gracefully.
**No mobile testing.** Banner overlaps the mobile nav or pushes content off-screen. Test mobile layouts.
**Auto-published outage banner with no human filter.** A 30-second status-page blip becomes a 30-second banner. Set a minimum duration before auto-publishing (5+ minutes).
**Regulatory acknowledgment with no enforcement.** "Please review the new privacy policy" — but users can dismiss without reading. If legal needs proof of acknowledgment, force the read+ack flow.
**No analytics.** You have no data on whether banners are seen / clicked / dismissed. Track events.
**No segment targeting.** Free-tier users see "Pro feature is degraded" banners. Filter by segment.
**Account-specific banners stored in user table.** Account-level info (your custom domain expired) ends up duplicated for every team member. Store at account scope; render to all members.
**Dismissals lost on logout.** User dismissed banner; logged out + back in; banner returns. Persist dismissals server-side, not in localStorage.
**No way to test before going live.** Marketing publishes a banner with a typo to all 50K users. Always have a "preview / test segment" mechanism.
**Banner blocks critical UX.** Sticky banner over the navigation makes the cancel button unreachable. Don't break the app to deliver the message.
**Dual surface mismatch.** Public status page says "everything is fine" while in-app banner says "outage." Resolve from one source of truth.
**Banner copy in HTML.** Embedded HTML allows raw markup that breaks layout. Use markdown with safe rendering.
**No "ended" / archive view.** Admin can't see what banners were live last month. Maintain a status-history view; useful for incident postmortems and compliance.
## See Also
- [Status Page](./status-page-chat.md) — public-facing status page
- [In-App Notifications](./in-app-notifications-chat.md)
- [Toast Notifications UI](./toast-notifications-ui-chat.md)
- [Notification Preferences & Unsubscribe](./notification-preferences-unsubscribe-chat.md)
- [In-Product Release Notes / What's New](./in-product-release-notes-whats-new-chat.md)
- [Account Suspension & Fraud Holds](./account-suspension-fraud-holds-chat.md)
- [Trial Countdown / Conversion UI](./trial-countdown-conversion-ui-chat.md)
- [Trial Extension / Save Offer UX](./trial-extension-save-offer-ux-chat.md)
- [Trial to Paid](./trial-to-paid-chat.md)
- [Quotas, Limits & Plan Enforcement](./quotas-limits-plan-enforcement-chat.md)
- [Dunning & Failed Payments](./dunning-failed-payments-chat.md)
- [Cookie Consent](./cookie-consent-chat.md)
- [Roles & Permissions](./roles-permissions-chat.md)
- [Internal Admin Tools](./internal-admin-tools-chat.md)
- [Service Level Agreements](./service-level-agreements-chat.md)
- [Incident Response](./incident-response-chat.md)
- [Inbound Webhooks](./inbound-webhooks-chat.md)
- [Outbound Webhooks](./outbound-webhooks-chat.md)
- [Empty States, Loading & Error States](./empty-states-loading-error-states-chat.md)
- [Sidebar Navigation Implementation](./sidebar-navigation-implementation-chat.md)
- [Settings & Account Pages](./settings-account-pages-chat.md)
- [Customer Support](./customer-support-chat.md)
- [Background Jobs & Queue Management](./background-jobs-queue-management-chat.md)