SaaS settings & billing
Profile, team and roles, plans and invoices, API keys and an audit log, as long scannable forms beside a section index.
Workspace owners, admins and developers. Shell: settings layout in a grouped-sidebar app shell. Open the live preview (on the mock backend). Set up snow-ui and the registries first (installation); the template guides cover other routers than Next.js.
What's included
Pages (registry:page)
Each page is a React component (a client component) that you mount from a route of your app with the data it shows; it reads the adapter, the routes and the formatting from SettingsTemplateProvider.
S8 Notifications (P2 in the design) is not part of this release.
Blocks (registry:block)
The template also uses Auth's choice-cards, file-dropzone, invite-dialog, secret-reveal, password-input, password-strength and otp-input.
Shared items
settings-types (SettingsAdapter, the data types, SettingsResult, settleSettings, auditQueryFromParams / auditQueryToParams), use-follow-search (the pages following the URL), format (createFormat: numbers, money in minor units, dates in a time zone, relative times against a fixed "now"), density (the layout variables and ThemePreference), and the mock data: mock-core and mock-settings (createMockSettingsAdapter, MOCK_SETTINGS_RULES, MOCK_NOW, mockSettingsData(scenario), mockAuditEvents, queryAuditEvents, auditCsv). No page or block imports the mock data: install mock-settings only to try the pages before your backend is wired (a registry test checks that no installable item reaches it, even indirectly).
The whole template: settings
@snow-ui-pro/settings is a registry:item without files of its own: its registryDependencies are every page and block above.
Install
Add the two registries to components.json (see installation), then:
npx shadcn@latest add @snow-ui-pro/settings
# Optional: the mock backend, to click through the pages first.
npx shadcn@latest add @snow-ui-pro/mock-settingsOr only some pages (@snow-ui-pro/members-page…), or a single block (@snow-ui-pro/data-table). npm dependencies: react-hook-form and @phosphor-icons/react.
Wire it up (Next.js App Router)
The provider holds the adapter, the routes, the formatting and the pages' edits between client-side navigations. Put it in the layout of the settings routes:
// app/settings/settings-provider.tsx
'use client'
import type { Route } from 'next'
import NextLink from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import type { ReactNode } from 'react'
import { SettingsTemplateProvider } from '@/components/snow-ui-pro/templates/settings/SettingsTemplate'
import type { LinkComponent } from '@/components/snow-ui-pro/lib/navigation'
import { settingsAdapter } from '@/lib/settings' // your SettingsAdapter
const Link: LinkComponent = ({ href, ...props }) => (
<NextLink href={href as Route} {...props} />
)
export function SettingsProvider({
now,
sidebarCollapsed,
children,
}: { now: string; sidebarCollapsed: boolean; children: ReactNode }) {
const router = useRouter()
const search = useSearchParams()
return (
<SettingsTemplateProvider
adapter={settingsAdapter}
navigate={(href) => router.push(href as Route)}
// Filters and tabs: a new URL without a server round trip, which
// Next.js syncs with useSearchParams.
replace={(href) => window.history.replaceState(null, '', href)}
// The pages follow it: Back and Forward restore a page as it first
// rendered, and its filters and tab from here.
search={search.toString()}
linkAs={Link}
now={now}
sidebarCollapsed={sidebarCollapsed}
timeZone="Europe/Lisbon"
workspaceName="Acme"
domain="acme.app"
onAppearanceChange={({ theme }) => {
// Apply (and persist in a cookie, so the server renders it).
const html = document.documentElement
if (theme === 'system') html.removeAttribute('data-theme')
else html.setAttribute('data-theme', theme)
}}
onSignOut={() => router.push('/sign-in')}
>
{children}
</SettingsTemplateProvider>
)
}// app/settings/layout.tsx — "now" from the server, so relative times match;
// the sidebar as the person left it (the library's cookie), without a flash
import { readSidebarState } from '@holakirr/snow-ui'
import { headers } from 'next/headers'
import { SettingsProvider } from './settings-provider'
export default async function Layout({ children }: { children: React.ReactNode }) {
const collapsed = readSidebarState((await headers()).get('cookie')) === false
return (
<SettingsProvider now={new Date().toISOString()} sidebarCollapsed={collapsed}>
{children}
</SettingsProvider>
)
}// app/settings/members/page.tsx — the data from your backend, on the server
import { MembersPage, type MembersTab } from '@/components/snow-ui-pro/templates/settings/MembersPage'
import { getMembersData } from '@/lib/settings'
type Props = { searchParams: Promise<{ tab?: string }> }
export default async function Page({ searchParams }: Props) {
const { tab } = await searchParams
const data = await getMembersData()
return <MembersPage {...data} tab={tab as MembersTab | undefined} />
}// app/settings/audit-log/page.tsx — the filters live in the URL
import { AuditLogPage } from '@/components/snow-ui-pro/templates/settings/AuditLogPage'
import { auditQueryFromParams } from '@/components/snow-ui-pro/lib/settings-types'
import { getAuditData, listAuditEvents } from '@/lib/settings'
type Props = { searchParams: Promise<Record<string, string | undefined>> }
export default async function Page({ searchParams }: Props) {
const query = auditQueryFromParams(await searchParams)
const data = await getAuditData()
return <AuditLogPage {...data} query={query} initial={await listAuditEvents(query)} />
}The audit log's filters and the members' tab follow the URL: after Back, Forward or a link they say what the URL says, and the events reload. Give the provider your router's search: a router may restore a page the way it first rendered it (Next.js does on Back and Forward), and the URL then wins over the page's first props. Without search the pages follow the browser's Back and Forward (popstate) between their own URLs only.
SettingsShell (used by every page) is the template's minimal host app: the sidebar with Home, Settings and Help, search, the account menu and the settings index. In your app, render the pages' content inside your own AppShellSidebar (or any shell) with SettingsLayout. routes defaults to /settings/…; override any of them (home, help, twoFactorSetup — Auth's U5 —, apiDocs).
The adapter
Resolve with { ok: false, error } for expected failures; reject only when the request failed — the pages show it as a network error with "Try again".
Payments
No page handles card numbers or takes a payment. "Replace" on the billing page opens a dialog with a clearly marked slot: pass your payment provider's hosted card element as paymentElement, and resolve replacePaymentMethod() with what to display (brand, last four, expiry) once the provider has saved it. Plan changes show the proration your provider computes (previewPlanChange) before changePlan; a downgrade whose preview lists usage over the new limits (overLimits) can't be confirmed.
Not revealing data
- The email change sends a code to the new address and doesn't say whether the address is in use; a conflict surfaces only after the code proves the address is the person's.
- Deleting the account, removing a member, revoking a key and deleting the workspace are confirmed in a dialog that names the object; the irreversible ones need the name typed.
- The CSV export prefixes cells that start with
=,+,-,@(CSV injection); keep that in your server's export.
The mock rules
createMockSettingsAdapter({ latencyMs, scenario, failOn }) answers after a fixed delay (0 in tests, 400 on the site) by these rules — anything else succeeds:
Scenarios (?scenario= on the site): default, trialing (an alert on Plans, an unverified email), past-due (plan changes disabled, a failed invoice), over-limit (an API meter over its limit), non-owner (an admin: the danger zone disabled). The clock is MOCK_NOW (16 June 2026, 09:16 in Lisbon).
Customising
- Strings. Every block takes
messages(English defaults). The pages are yours to edit: their copy is in the page components. - Density.
AppShellSidebar density(ordata-densitywithdensityClasseson your own root) sets--pro-section-gap,--pro-block-gap,--pro-panel-pad,--pro-row-heightand--pro-list-row; compact rows apply to fine pointers only. The account menu switches it; persist it like the theme. - Theme. The profile's theme previews at once and reverts on Discard; the account menu applies it. Both go through
onAppearanceChange. - Field states (library 5.2). The address and tax ID checks use
Input status="progress" | "success"with astatusLabel, buttons useloading, destructive menu itemsvariant="destructive".
Accessibility
- One
h1per page; after a client-side navigation between settings pages the newh1takes the focus. The shell starts with a skip link tomain; the top bar is aheader, the sidebar anav("Primary"), the section index anav("Settings"). - Forms follow the Pro form rules: a changed field is checked when it loses focus; on save, the first invalid field takes the focus and the save bar counts the errors. Busy buttons keep their label (
loading) and a second press is ignored. - Async checks announce "Checking" and the result through the field's status; filters announce the result count; toasts carry Undo or "Try again" when an action needs it.
- Focus after changes: removing a member, revoking a key or signing out a session moves the focus to the next row; a new key focuses its row once the secret dialog closes; closing a dialog returns to its trigger.
- The page scrolls focused fields clear of the sticky top bar and the save bar (WCAG 2.4.11).
- Checked in the site's end-to-end tests: every page at 375, 768 and 1280px in the light and dark themes with axe (WCAG 2.2 AA), no sideways scrolling from 320px, the scenario states, right to left, and each page's flows with the keyboard only, including the destructive confirmations and the error states.
Not yet
- The settings pages don't use
data-table's row selection and bulk actions orrecord-properties' editing in place (they came with the Commerce template, its guide). - At 768–1023px the sidebar starts expanded (the library's default) rather than as an icon rail; ⌘B / Ctrl+B or the menu button collapses it, and the choice is kept in a cookie (read on the server:
sidebarCollapsedabove).