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.

GroupSectionsWho can editSave pattern
Account (personal)Profile, Security, Notifications, AppearanceThe userForms: explicit. Toggles: autosave
WorkspaceGeneral, Members, Roles, Billing, API keysAdmins and ownersExplicit, with audit log
Danger zoneTransfer, deleteOwner onlyTyped 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:

app/(app)/settings/layout.tsxtsx
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.

Settings LayoutuiSettings page layout with a 200px side nav column and a content area, plus a SettingsSection for titled groups divided by hairlines. Tailwind grid based.npx shadcn@latest add ui.minidev.pro/r/settings-layout.json

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):

app/(app)/settings/notifications/notification-toggle.tsxtsx
"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.

app/(app)/settings/profile/profile-section.tsxtsx
"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:

app/(app)/settings/members/page.tsxtsx
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.
Role Permission MatrixuiRole and permission matrix with roles as columns and permissions as rows, each cell a labelled checkbox. Scrolls sideways on small screens, styled in Tailwind.npx shadcn@latest add ui.minidev.pro/r/role-permission-matrix.json

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:

app/(app)/settings/account/delete-account.tsxtsx
"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.

Settings PageblockA React settings page combining a profile form, theme picker, notification preferences and a danger zone for destructive actions under one header.npx shadcn@latest add ui.minidev.pro/r/settings-page.jsonDelete Account ConfirmuiAccount deletion step with a danger alert and a confirmation field: the delete button stays disabled until the user types DELETE. Guards against misclicks.npx shadcn@latest add ui.minidev.pro/r/delete-account-confirm.json
bash
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

Settings LayoutuiSettings page layout with a 200px side nav column and a content area, plus a SettingsSection for titled groups divided by hairlines. Tailwind grid based.npx shadcn@latest add ui.minidev.pro/r/settings-layout.jsonSettings PageblockA React settings page combining a profile form, theme picker, notification preferences and a danger zone for destructive actions under one header.npx shadcn@latest add ui.minidev.pro/r/settings-page.jsonProfile FormuiAn account profile form with a display name input, a bio textarea and a Save profile button, composed from FormField, Input and Textarea in React.npx shadcn@latest add ui.minidev.pro/r/profile-form.jsonTeam Member RowuiTeam member row with an initials avatar, name, email and a role badge, truncating cleanly in narrow lists. Built for team pages and member management in React.npx shadcn@latest add ui.minidev.pro/r/team-member-row.jsonInvite MembersuiA team invite panel with an email input, a Send button and badges for pending invitations. A starting point for workspace member management in SaaS settings.npx shadcn@latest add ui.minidev.pro/r/invite-members.jsonRole Permission MatrixuiRole and permission matrix with roles as columns and permissions as rows, each cell a labelled checkbox. Scrolls sideways on small screens, styled in Tailwind.npx shadcn@latest add ui.minidev.pro/r/role-permission-matrix.jsonApi Key ListuiReact settings panel listing API keys with masked previews, created date badges and a rotate button per key, plus a header action to create a new key.npx shadcn@latest add ui.minidev.pro/r/api-key-list.jsonDanger ZoneuiDanger zone settings card with a red outline, a warning that the action cannot be undone and a destructive delete button. Put it last on settings pages.npx shadcn@latest add ui.minidev.pro/r/danger-zone.json

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.

More guides