Banners
Ship announcement bars on the marketing and dashboard surfaces, each toggled by config.banner, with role and plan targeting plus dismissal on the dashboard.
Announcement bars are gated per surface by config.banner, with content defined per-app in apps/web/config/banner.ts. The two surfaces are independent - ship a marketing announcement without touching the dashboard.
The flag
config.banner holds two independent booleans. A generated project ships them false; the demo build sets both true.
banner: { marketing: false, dashboard: false },A surface renders nothing when its flag is false, and self-hides when its slot has no text - so emptying the copy removes it without touching the flag.
Marketing vs dashboard
| Aspect | Marketing | Dashboard |
|---|---|---|
| Flag | config.banner.marketing | config.banner.dashboard |
| Slot type | BannerSlot | DashboardBannerSlot |
| Rendering | Static, prerendered with the page | Resolved server-side per user in the dashboard layout |
| Dismissable | No | 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>. The marketing layout passes serializable props only, so the page stays prerenderable.
Slot fields
text, icon, variant, and link apply to both slots; the rest extend DashboardBannerSlot only.
| Field | Type | Default | Notes |
|---|---|---|---|
text | string | - | A banner.* i18n key, rendered as plain text; copy lives in @repo/i18n |
icon | Phosphor Icon | - | Imported directly from @phosphor-icons/react/ssr, rendered weight="fill" |
variant | BannerVariant | "primary" | primary / secondary / destructive / accent / muted, mapped to shadcn bg and fg |
link | string | - | Internal path (next-intl Link) or external URL (opens in a new tab) |
dismissible | boolean | true | Dashboard only - renders the ✕ |
roles | UserRole[] | all roles | Dashboard only - resolved from the session |
plans | string[] | all plans | Dashboard only - resolves the viewer's plan id lazily, only when set |
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)
}
};Targeting and dismissal
Both resolve server-side in the dashboard layout via matchesBannerTargeting and bannerContentKey from @repo/utils/helpers, so the banner never flashes.
- Targeting: an omitted or empty
roles/planslist matches everyone;plansresolves the viewer's plan id only after the role check passes. - Dismissal: persists in the
dashboard_banner_dismissedcookie for a year, keyed to the content - editing thetext,variant, orlinkmints a new key and re-shows the banner to everyone who dismissed the old one.
There is no maintenance mode. To warn users about downtime or an incident, enable the dashboard banner with a destructive variant and target the relevant roles or plans.