# React Settings Page: Section Nav, Save Bar, Danger Zone

> Build a React settings page with a section nav on desktop, an iOS-style push list on phones, a save bar for unsaved changes and a typed danger zone.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Build it: screens and apps](https://wingo-ui.com/blog/category/build)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/react-settings-page

## TL;DR

A good React settings page splits settings into sections with a left nav on desktop and an iOS-style list that pushes each section in on phones. Text fields collect into a draft that a save bar commits or discards, switches save on their own and roll back when the request fails, and destructive actions sit last in a danger zone behind a typed confirmation. With Wingo UI's Settings Layout that is one component plus your own fields and save handlers.

Every app grows a settings screen, and most of them are built last and in a hurry: one long form, a Save button at the bottom that nobody scrolls to, switches that look saved but are not, and a "Delete account" button sitting right next to "Change password". This tutorial builds a React settings page that avoids all of that, step by step: a section nav on desktop, an iOS-style push list on phones, a save bar that appears only when something changed, switches that save on their own, and a danger zone at the end. We build Wingo UI and the code uses our [Settings Layout](https://wingo-ui.com/components/settings-layout), so we are not neutral; the patterns work with any library.

## What does a good account settings page UI need?

A good account settings page UI needs five things: addressable sections, a visible save model, toggles that apply at once, a separate danger zone and a real phone layout. Each one comes from a product people already use:

- **Sections, each with its own address.** Profile, notifications, billing, security. A left column when there are many, tabs when there are a few. Support links should land on the right section, so the open section belongs in the URL.
- **A save model you can see.** Shopify's [Save Bar API](https://shopify.dev/docs/api/app-home/latest/apis/user-interface-and-interactions/save-bar-api) shows Save and Discard only while a form has unsaved changes, and its `leaveConfirmation()` asks the merchant before the app navigates away (as of October 2026). That is the model for text fields.
- **Toggles that apply at once.** A switch that waits for a Save button lies about its state. It should save immediately and flip back if the request fails.
- **Destructive actions last.** A separate block with a red outline, buttons instead of switches, and a confirmation that takes a deliberate act, like typing a word.
- **A phone layout.** On a 390px screen a sidebar does not fit. iOS Settings solved this long ago: a grouped list of sections, and tapping one pushes it in.

Many React settings page templates cover the first point and stop there. [Untitled UI's settings pages](https://www.untitledui.com/react/components/settings-pages), for example, list 21 layouts (profile, password, team, billing, notifications, integrations) and, as of October 2026, the page does not mention unsaved changes, a danger zone or a phone layout. Those are the parts that take the time, so they get the most space here.

## How do you set up the settings layout in React?

You set it up with one `SettingsLayout` that receives your sections as data and owns the nav and the save bar. Install it with the controls:

```bash
npx wingo-ui@latest add settings-layout switch input alert-dialog
```

The Settings Layout component is the frame: it takes a list of `sections`, renders the nav, swaps the active page with a short fade, and owns the save bar. Each section is an object with an `id`, a `label`, an optional `icon`, `group` (an eyebrow label in the sidebar and one inset group on phones), `description` (the subtitle in the phone list), `tone` (the phone icon tile) and `content`. Inside a section you compose `SettingsSection` (a titled group of rows) and `SettingsRow` (a label, a description and one control on the right). A row wires its label to the control for you, so tapping the label text flips a switch.

Here is the client view with three sections, a save bar for the text fields and switches that save on their own:

```tsx
// app/(app)/settings/settings-view.tsx
"use client"

import { Bell, TriangleAlert, User } from "lucide-react"
import { useState } from "react"
import { Input } from "@/components/ui/input"
import { SettingsLayout, SettingsRow, SettingsSection } from "@/components/ui/settings-layout"
import { Switch } from "@/components/ui/switch"
import { DangerZone } from "./danger-zone"

export type AccountSettings = { name: string; title: string; digest: boolean; mentions: boolean }
type Preference = "digest" | "mentions"

export function SettingsView({ initial, section }: { initial: AccountSettings; section: string }) {
  const [saved, setSaved] = useState(initial)
  const [draft, setDraft] = useState(initial)
  const [prefError, setPrefError] = useState<Partial<Record<Preference, string>>>({})
  // only text fields make the page dirty; switches save on their own
  const dirty = draft.name !== saved.name || draft.title !== saved.title

  const savePreference = async (key: Preference, value: boolean) => {
    setPrefError((e) => ({ ...e, [key]: undefined }))
    const res = await fetch("/api/settings/preferences", {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ [key]: value }),
    })
    if (!res.ok) {
      // the row shows this; the rejection makes the switch flip back
      setPrefError((e) => ({ ...e, [key]: "Could not save. Try again." }))
      throw new Error("Preference not saved")
    }
    setSaved((s) => ({ ...s, [key]: value }))
    setDraft((d) => ({ ...d, [key]: value }))
  }

  return (
    <SettingsLayout
      title="Settings"
      description="Your profile and how we reach you"
      defaultValue={section}
      onValueChange={(id) => {
        // keep any other query params, only swap the section
        const url = new URL(window.location.href)
        url.searchParams.set("section", id)
        window.history.replaceState(null, "", url)
      }}
      dirty={dirty}
      onSave={async () => {
        const res = await fetch("/api/settings/profile", {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ name: draft.name, title: draft.title }),
        })
        if (!res.ok) throw new Error("We could not save your profile. Try again.")
        setSaved(draft)
      }}
      onDiscard={() => setDraft(saved)}
      sections={[
        {
          id: "profile",
          label: "Profile",
          group: "Account",
          icon: <User />,
          tone: "info",
          description: draft.name,
          content: (
            <SettingsSection title="Public profile" description="Shown to your teammates.">
              <SettingsRow label="Full name" controlWidth="md">
                <Input value={draft.name} onValueChange={(name) => setDraft({ ...draft, name })} autoComplete="name" />
              </SettingsRow>
              <SettingsRow label="Job title" controlWidth="md">
                <Input value={draft.title} onValueChange={(title) => setDraft({ ...draft, title })} />
              </SettingsRow>
            </SettingsSection>
          ),
        },
        {
          id: "notifications",
          label: "Notifications",
          group: "Account",
          icon: <Bell />,
          tone: "warning",
          description: "Email digest and mentions",
          content: (
            <SettingsSection title="Email">
              <SettingsRow label="Daily digest" description="New deals and overdue invoices, at 8 AM." error={prefError.digest}>
                <Switch checked={saved.digest} onCheckedChange={(v) => savePreference("digest", v)} />
              </SettingsRow>
              <SettingsRow label="Mentions" description="When someone tags you in a note." error={prefError.mentions}>
                <Switch checked={saved.mentions} onCheckedChange={(v) => savePreference("mentions", v)} />
              </SettingsRow>
            </SettingsSection>
          ),
        },
        {
          id: "danger",
          label: "Danger zone",
          icon: <TriangleAlert />,
          danger: true,
          description: "Delete the account",
          content: <DangerZone />,
        },
      ]}
    />
  )
}
```

Three details carry most of the weight. `dirty` compares the draft to the last saved values, so typing a name and then deleting the change hides the bar again. `onSave` returns a promise: while it runs the button spins, a resolved promise flashes "Changes saved" and slides the bar away, and a thrown error keeps the bar, shakes it and shows your message. `onDiscard` puts the saved values back, and the layout also calls it when someone confirms leaving a section with unsaved changes.

> Live demo (Settings Layout): Tap Profile to push it in, edit the name and watch the save bar slide up with Discard and Save, then tap the back button. In a frame this narrow the sections are an iOS-style list; at 768px and wider the same component shows a sidebar. Try it at [Settings Layout](https://wingo-ui.com/components/settings-layout) and install it with `npx wingo-ui@latest add settings-layout`.

## How do you put each section in the URL in a Next.js settings page?

Read the section from the query string in the server page and write it back with `history.replaceState` on the client. In Next.js 16, `searchParams` is a promise, so await it:

```tsx
// app/(app)/settings/page.tsx
import { getAccountSettings } from "@/server/settings" // your data layer
import { SettingsView } from "./settings-view"

export default async function SettingsPage({
  searchParams,
}: {
  searchParams: Promise<{ section?: string }>
}) {
  const [{ section }, settings] = await Promise.all([searchParams, getAccountSettings()])
  return <SettingsView initial={settings} section={section ?? "profile"} />
}
```

The view above updates the address with `window.history.replaceState` in `onValueChange`. Next.js [supports the native History API](https://nextjs.org/docs/app/getting-started/linking-and-navigating#native-history-api) and keeps `usePathname` and `useSearchParams` in sync with it, and the call rewrites the address without a navigation, so switching sections never refetches the settings or reruns the server page. Building the URL from `window.location.href` keeps any other query params, which a bare `?section=` string would drop. An unknown id in the URL falls back to the first enabled section.

There is one phone trade-off to know about. A query parameter selects the section, but on a phone the layout still opens on the grouped list, the way iOS Settings does. If deep links must open the section itself on phones, give each section an `href` and make it a real route (`/settings/notifications`) with `value` controlled from the pathname: the layout opens a routed section already pushed. Sections with an `href` render `children` instead of `content`, and guarding unsaved changes across routes becomes your router's job.

## How should switches save on a settings page?

Immediately, with a rollback. The [Switch](https://wingo-ui.com/components/switch) treats a promise from `onCheckedChange` as a save: it flips at once, shows a small spinner in the thumb, ignores clicks until the promise settles, and flips back if it rejects. That is why `savePreference` in the view throws on a failed response and only updates `saved` after success. `checked` stays bound to the saved value, so while the request runs the switch shows the pending value, and afterwards it follows `checked` again and never claims a state the server does not have.

The error message is the part that trips people up. A Switch with its own `label` shows its `errorText` under the label on a failed save, but inside a `SettingsRow` the switch has no label of its own: the row supplies it. A bare switch still shakes, flips back and announces `errorText` to screen readers, yet nothing visible says why. That is why the view keeps a `prefError` map and passes it to the row's `error` prop, which renders the message under the description and links it to the switch with `aria-describedby`. Clear the entry at the start of each attempt so a retry that succeeds removes the message.

Keeping switches out of `dirty` matters. If a toggle also raised the save bar, people would see "You have unsaved changes" for something that already saved, and the next Discard would appear to undo it. One page, two save models, and each control makes it obvious which one it uses.

> Live demo (Switch): Flip New messages, then press Simulate an error and flip it again: the switch turns at once, spins while it saves, then rolls back and shows why. Try it at [Switch](https://wingo-ui.com/components/switch) and install it with `npx wingo-ui@latest add switch`.

The Switch is free. Inside a `SettingsRow` the bare track gets a 44px hit area on touch screens, the row's label is wired to it so tapping "Daily digest" flips it too, and a vertical swipe across the track still scrolls the page.

## How do you build a danger zone that cannot be clicked by accident?

Put it last, mark it, and make the final step deliberate. `SettingsSection` takes `danger` for a red ring and title, `SettingsRow` takes `danger` for a red label, and the section item takes `danger: true` so the phone list shows it in red too. The confirmation is an [Alert Dialog](https://wingo-ui.com/components/alert-dialog) with a typed word:

```tsx
// app/(app)/settings/danger-zone.tsx
"use client"

import { Trash2 } from "lucide-react"
import { useState } from "react"
import { AlertDialog } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { SettingsRow, SettingsSection } from "@/components/ui/settings-layout"

export function DangerZone() {
  const [open, setOpen] = useState(false)
  const [typed, setTyped] = useState("")

  return (
    <>
      <SettingsSection title="Danger zone" description="Actions that cannot be undone." danger>
        <SettingsRow label="Delete account" description="Removes your profile and every workspace you own." danger>
          <Button variant="soft" tone="danger" size="sm" leftIcon={<Trash2 />} onClick={() => setOpen(true)}>
            Delete account
          </Button>
        </SettingsRow>
      </SettingsSection>

      <AlertDialog
        open={open}
        onOpenChange={(next) => {
          setOpen(next)
          if (!next) setTyped("")
        }}
        tone="danger"
        icon={<Trash2 />}
        title="Delete your account?"
        description="Your profile, workspaces and invoices will be deleted for good."
        confirmLabel="Delete account"
        cancelLabel="Cancel"
        confirmDisabled={typed !== "DELETE"}
        onConfirm={async () => {
          const res = await fetch("/api/account", { method: "DELETE" })
          if (!res.ok) throw new Error("We could not delete the account. Try again.")
          window.location.assign("/goodbye")
        }}
      >
        <Input label="Type DELETE to confirm" value={typed} onValueChange={setTyped} autoComplete="off" spellCheck={false} />
      </AlertDialog>
    </>
  )
}
```

Focus lands on Cancel when the dialog opens, clicking the dimmed page does nothing, and the action stays disabled until the word matches. `onConfirm` returns a promise, so the button spins while the request runs and a thrown error shakes the card and shows the message above the buttons instead of closing it. Reset `typed` when the dialog closes, or the next open starts already armed.

## How does the settings page work on a phone?

Settings Layout measures its own width, and below 768px it switches the nav. The default `mobileNav="list"` is the iOS pattern: the page title becomes a large title over a grouped list, each section is a row with its icon tile and description, and tapping a row pushes the section in. The back button, Escape or a swipe to the right takes you back. Two alternatives exist for shorter pages: `"tabs"` (sticky scrolling pill tabs) and `"select"` (a select that opens a bottom sheet with the same groups).

The save bar changes too. On a phone it always pins to the bottom, sits above the safe area and above a bottom tab bar that publishes its height as `--bottom-bar-offset`, lifts over the on-screen keyboard, and the layout pads the content so the bar never covers the last row. If you have fought `position: fixed` on iOS before, our post on [the iOS Safari keyboard and fixed bars](https://wingo-ui.com/blog/ios-safari-keyboard-position-fixed) explains why that last part needs care, and the [bottom navigation bar guide](https://wingo-ui.com/blog/react-bottom-navigation-bar) covers the tab bar side of `--bottom-bar-offset`.

On a desktop, the save button shows a key hint for `saveShortcut` (default `mod+s`: Cmd+S on a Mac, Ctrl+S elsewhere), hidden on touch screens. The shortcut is bound only while there is something to save, even inside a text field, so the browser's own Save Page still works the rest of the time. Pass `saveShortcut=""` to turn it off.

## What if you only need the phone list of settings?

Use the [List](https://wingo-ui.com/components/list) component when a whole layout is more than you need, such as a "Me" tab in a phone-first app with a few rows that link to pages, show a current value or toggle something. It draws the iOS grouped panel on its own:

```tsx
import { Bell, Globe, User } from "lucide-react"
import { List, type ListGroupData } from "@/components/ui/list"
import { Switch } from "@/components/ui/switch"

const groups: ListGroupData[] = [
  {
    id: "account",
    label: "Account",
    items: [
      { id: "profile", title: "Profile", description: "emma.carter@northwind.app", icon: <User />, href: "/settings/profile" },
      { id: "notifications", title: "Notifications", icon: <Bell />, trailing: <Switch defaultChecked aria-label="Notifications" /> },
      { id: "language", title: "Language", icon: <Globe />, value: "English", href: "/settings/language" },
    ],
  },
]

export function MeTab() {
  return <List groups={groups} />
}
```

Rows with an `href` end in a chevron, `value` shows the current choice on the right, and `trailing` takes any control. When a row has both an `href` and a Switch in `trailing`, the row gets a stretched pressable instead of a wrapping button, so the switch never nests inside a link. The full Settings Page block uses the same List for its signed-in devices.

## Is there a complete React settings page template?

Yes, if you would rather start from the finished screen. The [Settings Page](https://wingo-ui.com/components/settings-page) block is built on Settings Layout and ships six sections: profile (photo upload with crop, phone, time zone), account (email change with a 6-digit code, workspace address check), notifications (a switch per event and channel, quiet hours, digest), billing (plan, usage, invoices that turn into cards on phones), security (password, a three-step two-factor setup with a QR code and backup codes, sessions) and a danger zone with export and a typed delete. Its section ids are Romanian keys behind English labels: `profil`, `cont`, `notificari`, `facturare`, `securitate`, `pericol`.

```bash
npx wingo-ui@latest add settings-page
```

```tsx
"use client"

import { SettingsPage } from "@/components/blocks/settings-page"

export function SettingsRoute() {
  return (
    <SettingsPage
      sections={["profil", "notificari", "securitate", "pericol"]}
      onSave={async (section, values) => {
        const res = await fetch(`/api/settings/${section}`, { method: "PUT", body: JSON.stringify(values) })
        if (!res.ok) throw new Error("We could not save. Please try again.")
      }}
      onPreferenceChange={async (preference, value) => {
        const res = await fetch("/api/settings/preferences", { method: "PATCH", body: JSON.stringify({ preference, value }) })
        if (!res.ok) throw new Error("Could not save. Try again.")
      }}
      onDeleteAccount={async () => {
        const res = await fetch("/api/account", { method: "DELETE" })
        if (!res.ok) throw new Error("We could not delete the account. Try again.")
        window.location.assign("/goodbye")
      }}
    />
  )
}
```

`onSave` covers the text fields of the open section and `onPreferenceChange` the switches that save on their own, with the same rollback as above (here a rejection flips the switch back with a toast). Both handlers must throw on a failed response: `fetch` only rejects on a network error, so a 500 that you do not turn into a throw counts as a successful save. The preference keys are paths such as `notificari.quietHours` or `securitate.loginAlerts`. Every string is a `labels` prop and every piece of data has its own prop (`profile`, `billing`, `sessions`), so you replace the sample one piece at a time. Our [Next.js admin dashboard tutorial](https://wingo-ui.com/blog/nextjs-admin-dashboard-tutorial) wires this block to Server Actions inside a full back office, and the rest of the [build category](https://wingo-ui.com/blog/category/build) covers other complete screens.

> Live demo (Settings Page): Open Delete account and type DELETE to enable the button, or press Export to watch the export count up in a toast first. Try it at [Settings Page](https://wingo-ui.com/components/settings-page) and install it with `npx wingo-ui@latest add settings-page`.

## What should you do next?

Start with the layout and one section, wire its save, then add the switches and the danger zone. The [Switch](https://wingo-ui.com/components/switch) and [Input](https://wingo-ui.com/components/input) are free, while Settings Layout, List, Alert Dialog and the Settings Page block are part of Wingo UI Pro ([pricing](https://wingo-ui.com/pricing)). Install the layout with `npx wingo-ui@latest add settings-layout` and build your first section from the code above.

## Components in this post

- [Settings Layout](https://wingo-ui.com/components/settings-layout) (Pro): A React settings page layout like macOS and iOS: a section nav, grouped label and control rows, and a save bar that slides up on changes. Install: `npx wingo-ui@latest add settings-layout`
- [Settings Page](https://wingo-ui.com/components/settings-page) (Pro): A React account settings page for a back office: profile, account, notifications, billing, security and a danger zone, with a save bar. Install: `npx wingo-ui@latest add settings-page`
- [Switch](https://wingo-ui.com/components/switch) (Free): A React toggle switch for settings that apply at once: a spring thumb that stretches and drags, thumb icons, and async saves that roll back. Install: `npx wingo-ui@latest add switch`
- [List](https://wingo-ui.com/components/list) (Pro): A React list for settings, contacts, inboxes and orders: iOS grouped panels, selection, row menus, swipe actions and drag to reorder. Install: `npx wingo-ui@latest add list`
- [Alert Dialog](https://wingo-ui.com/components/alert-dialog) (Pro): A React alert dialog for confirmations: an async confirm that loads, fails inline and shakes, plus an await confirm() API. Install: `npx wingo-ui@latest add alert-dialog`
- [Input](https://wingo-ui.com/components/input) (Free): A React text input: icons and addons inside the box, a clear button, a loading spinner, a character counter and floating labels. Install: `npx wingo-ui@latest add input`

## FAQ

### Should a settings page save automatically or use a Save button?

Use both, each for its own kind of control. Switches and other single-choice toggles save the moment they change and flip back if the request fails, while text fields collect into a draft that a save bar commits, because a half-typed name should never reach the server.

### How do I warn users about unsaved changes on a settings page?

Track a dirty flag by comparing the draft to the last saved values, show a save bar while it is true, ask before switching sections, and register a beforeunload handler so closing the tab warns too. Settings Layout does all three from its dirty, confirmLeave and warnBeforeUnload props.

### How should a settings page look on mobile?

Turn the section nav into a grouped list, like iOS Settings, where tapping a row pushes that section in with a back button and swipe back. Pin the save bar above the tab bar and the safe area so it never covers the last row or hides behind the on-screen keyboard.

### What belongs in a settings danger zone?

Actions that cannot be undone, such as deleting the account, transferring ownership or removing a workspace. Put the zone last, give it a red outline and title, use a button instead of a switch, and ask for a typed confirmation before running the action.

### Is there a ready-made React settings page template?

Wingo UI's Settings Page block ships profile, account, notifications, billing, security and danger zone sections with a save bar and phone layout, and installs with npx wingo-ui@latest add settings-page. It is part of Wingo UI Pro; the Switch and Input it uses are free.

---

Source: https://wingo-ui.com/blog/react-settings-page
