React
How to build a settings page that survives growth
A settings page survives growth when sections are data, not copied JSX. Here is the structure, per-section saving and the limit of ready blocks.
Key points
- One section, one route and one save: not twelve tabs on a single page.
- Settings are data: adding an option must cost one object.
- The catalogue blocks declare “see source”: check the licence before you ship.
A settings page that survives growth is a list of sections declared as data, not a row of hand-written components. Adding an option must cost one object, not a copy of JSX.
The breaking point is predictable: the third section. First comes per-section saving, then the first field that depends on another, then the first permission denied.
One section, one save
- One route per section: /settings/notifications must exist as a link.
- Form state kept local and saved explicitly, not by an invisible autosave.
- Confirmation and error next to the button that produced them.
- Disabled fields explained, not just switched off.
- Permissions read on the server: the client does not decide who can change what.
What is in the catalogue
The 1st-pouf block settings shows tabbed sections with react-hook-form; openlmis-settings-list is the row variant with badges and read-only values. Browse the settings category. Sources: github.com/moji2002/1st-pouf and github.com/SolDevelo/soldevelo-registry, licence declared upstream as “see source”.
The narrow category is not a hole in the catalogue: it says that “settings” is not a kind of page. The 1st-pouf block declares no categories and is found by title, not by filter. Search by name, open the code and read how it handles the fields.
Data, not columns
npx shadcn@latest add tabs switch label
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
type Setting = {
id: string;
label: string;
description: string;
defaultOn: boolean;
};
export function SettingsSection({ title, settings }: { title: string; settings: Setting[] }) {
return (
{title}
{settings.map((setting) => (
{setting.label}
{setting.description}
))}
);
}
A concrete example: the section is an object
Three files are enough. A config file declares the sections, a dynamic route reads the slug, and the component draws the rows. Adding “security alerts” costs one array entry and one row in the values table; no page gets copied.
export type Field = {
id: string;
label: string;
help: string;
kind: "switch" | "select";
options?: string[];
dependsOn?: { field: string; equals: boolean };
permission: "member" | "admin";
};
export const sections = {
notifications: {
title: "Notifiche",
route: "/settings/notifications",
fields: [
{ id: "digest", label: "Riepilogo settimanale", help: "Una email il lunedì.", kind: "switch", permission: "member" },
{ id: "mentions", label: "Menzioni", help: "Solo quando qualcuno ti nomina.", kind: "switch", dependsOn: { field: "digest", equals: true }, permission: "member" },
{ id: "channel", label: "Canale", help: "Dove arriva il riepilogo.", kind: "select", options: ["email", "slack"], permission: "admin" },
],
},
} satisfies Record;
The Field type is the contract. Whoever adds a field never touches the JSX: they pick kind, write help and declare permission. The dependsOn entry switches Mentions off while the digest is off, and the reason lives in the data, not in a hidden branch.
When the page grows
With a schema, a new setting is one array entry and one database field. Permissions, audit log and inherited values stay outside the component: the server decides them. If one field depends on another, the dependency lives in the schema, not in an if inside the JSX.
Limitation: both blocks declare “see source” as the licence, and the 1st-pouf settings block carries fourteen registryDependencies. It is not a component you copy in five minutes: read it as a structural reference and check the licence before you ship.
The decision rule
Rule: a field that needs a permission different from its neighbours, or that depends on another field, gets a section with its own route. Everything else stays a row in the list. The threshold avoids both extremes: twelve tabs on one page, and one page for a single switch.
The mistake to avoid
The mistake to avoid is trusting defaultChecked. The switch shows the new value before the response arrives, and when the save fails nobody puts it back: the page claims “on” for a value the database never received. Keep the value in state, draw the switch from it, and restore the previous state when the response is an error.
To pick the right block, read Best settings blocks 2026 and the 12-point checklist for accessible React components. If you are deciding whether to start from a ready theme, the comparison Free or premium shadcn themes puts the costs on the table.
Frequently asked questions
One page or one route per section?
One route per section, because the direct link is the main job of a settings page. A page with twelve tabs cannot be linked or reloaded without losing your place.
Autosave or a Save button?
A Save button per section, except for trivial settings such as a toggle. An invisible autosave makes it impossible to tell what was written and when.
How do I handle disabled fields?
With an explanation next to the field and a readable reason, not just a disabled attribute. People who do not understand the block raise a ticket.
Where do permissions live?
On the server. The client can hide a control, but the decision about who may change a value must be made and checked server-side.
Can I use the catalogue settings blocks?
You can read and adapt them, but the licence is declared as “see source”. Check the linked repository before you put them in a product.