Banners
Add marketing and dashboard announcement banners in your Next.js SaaS, toggled by config.banner with role and plan targeting and dismissal.
Announcement bars gated by config.banner per surface; content lives per-app in apps/web-next/config/banner.ts. Each surface is independent - ship a marketing announcement without touching the dashboard.
The flag
config.banner holds two independent booleans. A generated project ships them false (banners off); the demo build sets both true.
banner: { marketing: false, dashboard: false },| Behavior | Effect |
|---|---|
marketing: false | Marketing bar not rendered |
dashboard: false | Dashboard bar not rendered |
Flag on, slot text empty | Surface still self-hides (no text = nothing to show) |
| Content edits | Live in apps/web-next/config/banner.ts, never in @repo/config |
Marketing vs dashboard
| Aspect | Marketing banner | Dashboard banner |
|---|---|---|
| Flag | config.banner.marketing | config.banner.dashboard |
| Slot type | BannerSlot | DashboardBannerSlot |
| Rendering | Static, prerendered with the page | Rendered in the authenticated shell |
| Dismissable | No - always shown | Yes (dismissible, default true) |
| Targeting | Everyone | By roles and/or plans |
| Use for | Launch notices, promos, global news | Plan-specific notices, upgrade nudges, role alerts |
Both surfaces render the same "use client" <AnnouncementBanner> component. The marketing layout renders it with serializable props only, so the page stays prerenderable - but the component itself still ships as client JS.
Content fields
| Field | Type | Notes |
|---|---|---|
text | string | i18n key, rendered as plain text (no HTML). Copy lives in @repo/i18n translations |
icon | Phosphor Icon | Imported directly from @phosphor-icons/react/ssr, rendered weight="fill" |
variant | BannerVariant | "primary" (default) | "secondary" | "destructive" | "accent" | "muted" - shadcn bg/fg |
link | string | Optional. Internal path (next-intl Link) or external URL (opens in a new tab) |
Dashboard-only fields
| Field | Type | Default | Notes |
|---|---|---|---|
dismissible | boolean | true | Renders the ✕ button |
roles | UserRole[] | omit = all roles | Resolved from the session (free) |
plans | string[] | omit = all plans | Lazily resolves the viewer's plan id only when set and the role check passed |
Targeting and dismissal are resolved server-side in the dashboard layout (via matchesBannerTargeting / bannerContentKey from @repo/utils/helpers), so the banner never flashes. Dismissal persists in the dashboard_banner_dismissed cookie (1-year lifetime) keyed to the content - editing the copy mints a new key and re-shows it.
export const bannerConfig: BannerSlots<Icon> = {
marketing: {
text: "banner.marketing", // i18n key
icon: GiftIcon,
variant: "primary",
link: "/pricing"
},
dashboard: {
text: "banner.dashboard",
icon: SparkleIcon,
variant: "primary",
link: "/settings/developers"
// dismissible?: boolean (default true)
// roles?: UserRole[] (omit = everyone)
// plans?: string[] (omit = every plan)
}
};There is no maintenance mode. To warn users about upcoming downtime or incidents, enable the dashboard banner and target the relevant roles/plans.
Frequently asked questions
Can I show a banner only to paid users?
Yes - use the dashboard banner and set plans to the paid plan IDs. Users on other plans won't see it.
Why won't my marketing banner go away when I click it? The marketing banner is intentionally non-dismissable and prerendered. If you need a closeable bar, use the dashboard banner, which persists dismissal in a cookie.
Where do I change the banner text and link?
Edit the slot's link, icon, and variant in apps/web-next/config/banner.ts; the text is an i18n key whose copy lives in @repo/i18n translations. The config.banner flag only toggles each surface on or off.
Do I need both banners enabled?
No. The marketing and dashboard booleans are independent - enable either, both, or neither.