Design a SaaS settings page that scales
Design a SaaS settings page in React that scales: information architecture, autosave vs explicit save, team roles, API keys and safe destructive actions.
By MiniDev13 min read
A SaaS settings page that scales separates personal settings from workspace settings, gives each group its own URL, and applies one save pattern per section: toggles save instantly, text forms save with an explicit button. Destructive actions sit at the bottom behind a typed confirmation, and every permission is checked on the server, not just hidden in the UI. This guide builds that structure in React and Next.js with the free MiniDev UI settings components.
Information architecture first
Settings pages rot because they grow by accretion: every feature adds a toggle wherever there is room. Decide the structure before the first form. Two questions sort almost every setting: who owns it (the person or the workspace) and how often it changes.
| Group | Sections | Who can edit | Save pattern |
|---|---|---|---|
| Account (personal) | Profile, Security, Notifications, Appearance | The user | Forms: explicit. Toggles: autosave |
| Workspace | General, Members, Roles, Billing, API keys | Admins and owners | Explicit, with audit log |
| Danger zone | Transfer, delete | Owner only | Typed confirmation |
- Give each section a route (
/settings/profile,/settings/members). People bookmark them, support links to them, and each page loads only its own data. - Order sections by frequency of use. Profile and notifications first, API keys and danger zone last.
- Name sections with nouns people already use. "Members" beats "Collaboration".
- Keep billing under settings in the nav, but give it its own page; the billing page guide covers it.
The layout: a nav column and sections
SettingsLayout is a two-column grid: a 200px nav slot on the left from the md breakpoint, content on the right, stacked on small screens. The same file exports SettingsSection, which renders an h2 title, an optional description and a bottom border between sections. Put the settings nav in a nested layout so it persists across section routes:
import { PageHeader } from "@/components/ui/page-header"
import { SettingsLayout } from "@/components/ui/settings-layout"
import { SettingsNav } from "./settings-nav"
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="space-y-6">
<PageHeader title="Settings" description="Manage your account and this workspace." />
<SettingsLayout nav={<SettingsNav />}>{children}</SettingsLayout>
</div>
)
}
SettingsNav is a client component that renders next/link items with aria-current="page" on the active one, grouped under small "Account" and "Workspace" labels. The sidebar layout guide shows the active-link pattern in detail. On phones the nav stacks above the content, so keep it short or swap it for a select below md.
Save patterns: autosave or explicit
Mixing patterns inside one section is the most common settings bug: a user flips a toggle (saved), edits a name (not saved), and leaves. Pick one pattern per section using this rule:
- Autosave binary, independent choices: notification toggles, theme, a single select. The control is the save button.
- Explicit save anything typed, anything validated, and fields that only make sense together (a billing address, an SSO configuration). Show one Save button per section, disabled until something changes.
- Confirm anything irreversible or that affects other people: role changes, removing a member, rotating a key, deleting data.
Autosave toggles
Switch has a settings-row mode: pass label and description and the whole row becomes clickable. Its loading prop shows a spinner in the thumb, blocks toggling and announces "Saving" to screen readers. Update optimistically, then revert and explain if the server rejects it (see optimistic UI):
"use client"
import * as React from "react"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
import { setNotification } from "./actions"
export function NotificationToggle({ id, label, description, initial }: {
id: string; label: string; description: string; initial: boolean
}) {
const [on, setOn] = React.useState(initial)
const [saving, setSaving] = React.useState(false)
return (
<Switch
label={label}
description={description}
checked={on}
loading={saving}
onCheckedChange={async (next) => {
setOn(next)
setSaving(true)
const res = await setNotification(id, next)
setSaving(false)
if (!res.ok) {
setOn(!next)
toast.error("Could not update " + label.toLowerCase(), { description: res.error })
}
}}
/>
)
}
NotificationPreferences shows the visual pattern with three hardcoded rows; replace its array with your own list of NotificationToggles.
Explicit forms
For typed fields, use a form with a server action and useActionState. FormField wires the label and renders error with role="alert". React resets uncontrolled fields after a form action, so return the submitted values and feed them back as defaultValue; otherwise a validation error wipes what the user typed.
"use client"
import { useActionState } from "react"
import { FormField } from "@/components/ui/form-field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
import { updateProfile, type ProfileState } from "./actions"
export function ProfileSection({ name, bio }: { name: string; bio: string }) {
const [state, action, pending] = useActionState<ProfileState, FormData>(updateProfile, {})
return (
<form action={action} className="max-w-md space-y-3">
<FormField id="name" label="Display name" required error={state.errors?.name}>
<Input id="name" name="name" defaultValue={state.values?.name ?? name} aria-invalid={!!state.errors?.name} />
</FormField>
<FormField id="bio" label="Bio" description="Shown on your public profile.">
<Textarea id="bio" name="bio" defaultValue={state.values?.bio ?? bio} />
</FormField>
<Button type="submit" disabled={pending}>{pending ? "Saving" : "Save profile"}</Button>
</form>
)
}
ProfileForm is the same layout as a static demo: its name field has a hardcoded defaultValue and its submit handler only calls preventDefault. Treat it as a starting point and wire it as above.
Members, invites and roles
TeamMemberRow takes name, email and role, shows initials in an avatar and the role as an outline badge. Rows carry a bottom border, so wrap the list in one bordered container. Render the list in a server component and gate management controls on the viewer's role:
import { SettingsSection } from "@/components/ui/settings-layout"
import { TeamMemberRow } from "@/components/ui/team-member-row"
import { InviteForm } from "./invite-form"
export default async function MembersPage() {
const { user, workspace } = await requireSession()
const members = await getMembers(workspace.id)
const canManage = can(user, workspace, "members:manage")
return (
<>
<SettingsSection title="Members" description={members.length + " people have access to " + workspace.name + "."}>
<div className="rounded-xl border border-border">
{members.map((m) => (
<TeamMemberRow key={m.id} name={m.name} email={m.email} role={m.role} />
))}
</div>
</SettingsSection>
{canManage ? (
<SettingsSection title="Invite" description="Invites expire after 7 days.">
<InviteForm />
</SettingsSection>
) : null}
</>
)
}
InviteMembers is the invite card design (email input, Send button, pending invites as badges) with sample content; copy its markup into InviteForm and connect it to a server action. RolePermissionMatrix takes roles and permissions string arrays and renders a table of checkboxes with accessible labels such as "Admin Invite". Its checkboxes are uncontrolled with a demo default (every role except "Member" gets everything, Member gets only "Read"), so for real data edit your copy to accept a value map and an onChange.
Enforce on the server, reflect in the UI
- Every server action checks the permission again. Hiding a button is a courtesy, not access control.
- Hide controls a role can never use (a Member does not need a greyed-out Delete workspace button). Disable, with a reason in a tooltip, controls that are temporarily unavailable.
- Prefer a few fixed roles (Owner, Admin, Member, Viewer) over a fully custom matrix until customers ask. A matrix is powerful and hard to audit.
- Log role changes, invites and removals with who did it and when. Admins will ask.
API keys
ApiKeyList takes keys: { id, name, preview, created }[] and renders each with a monospace preview and a Rotate button; the Create key and Rotate buttons have no handlers, so add them in your copy. The rules that matter live on the server:
- Show the full secret exactly once, at creation, with a copy button. Store only a hash.
- Store a short prefix and the last four characters for the preview (
md_live_••••8f2a) so people can tell keys apart. - Rotation creates a new key and keeps the old one valid for a stated grace period, then revokes it. Instant rotation breaks production integrations.
- Show last-used time. It is the fastest way for someone to know which key is safe to delete.
Destructive actions
Keep destructive actions at the bottom of the relevant page, visually separated. DangerZone is that container: a danger-tinted border, a title, a sentence of consequences and a destructive button (sample copy, no handler). DeleteAccountConfirm is the confirmation body: an inline alert, a field, and a Delete button that stays disabled until the user types DELETE, then calls onConfirm. Put it in an AlertDialog so focus is trapped and the rest of the page is inert:
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { AlertDialog, AlertDialogContent, AlertDialogTitle, AlertDialogDescription } from "@/components/ui/alert-dialog"
import { DeleteAccountConfirm } from "@/components/ui/delete-account-confirm"
import { deleteAccount } from "./actions"
export function DeleteAccount() {
const [open, setOpen] = React.useState(false)
const [pending, startTransition] = React.useTransition()
return (
<>
<Button variant="destructive" size="sm" onClick={() => setOpen(true)}>Delete account</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
<AlertDialogDescription>
Your profile, 3 workspaces you own and their projects are removed after 14 days. You can cancel from the email we send.
</AlertDialogDescription>
<DeleteAccountConfirm className="mt-4" onConfirm={() => startTransition(() => deleteAccount())} />
<Button variant="ghost" size="sm" className="mt-2" disabled={pending} onClick={() => setOpen(false)}>Cancel</Button>
</AlertDialogContent>
</AlertDialog>
</>
)
}
A typed confirmation stops accidents, not attackers. The deleteAccount action must re-check the session, require recent authentication for irreversible steps, and ideally schedule deletion with a grace period and an email, so a mistake or a hijacked session can be undone.
- Say exactly what goes: counts, names, and whether billing stops.
- For a workspace, ask people to type the workspace name, not a generic word. It proves they are on the right one.
- Offer the gentler alternative next to the button: leave the workspace, transfer ownership, or export data first.
Keeping it fast as it grows
- Each section page is a server component that loads only its data, so a slow members query never delays the profile page.
- Add
ids to sections and link to them (/settings/notifications#security) from emails and empty states. - Once you pass about a dozen sections, add settings to a command palette so people can jump straight to "API keys".
- Confirm autosaves with a quiet toast only on failure; confirm explicit saves inline or with a short toast. The toast guide covers when each fits.
Components used
The settings category holds settings-layout, profile-form, team-member-row, invite-members, role-permission-matrix, danger-zone, delete-account-confirm and notification-preferences; api-key-list is in developer tools. The SettingsPage block stacks PageHeader, ProfileForm, ThemePicker, NotificationPreferences and DangerZone on one page, which suits small apps before you split into routes. When a product needs settings, billing and roles built end to end, the MiniDev studio builds complete apps on this kit.
npx shadcn@latest add https://ui.minidev.pro/r/settings-layout.json https://ui.minidev.pro/r/team-member-row.json https://ui.minidev.pro/r/delete-account-confirm.json
npx shadcn@latest add https://ui.minidev.pro/r/<name>.json
Components used in this guide
Frequently asked questions
Should settings autosave or use a Save button?
Autosave independent toggles and single selects, because the control itself communicates the change. Use an explicit Save button for text fields, validated input and groups of related fields. Never mix both inside one section.
How should a settings page be structured in Next.js?
Use a nested settings/layout.tsx with the section nav, and one route per section such as settings/profile and settings/members. Each page is a server component that loads its own data and renders client components only for interactive controls.
Is hiding buttons enough to enforce roles and permissions?
No. Hide or disable controls for clarity, but check the permission again in every server action and API route, because requests can be made without your UI.
What is the safest pattern for deleting an account?
A dialog that names what will be deleted, a typed confirmation, a server-side re-check with recent authentication, and a scheduled deletion with a grace period and a confirmation email.