You wire up auth on day one. Then someone asks: can I change my profile photo? Then: how do I update my password? Then: can you email me less?
Suddenly you have three features, one route, and code that is harder to maintain than the signup flow that took three times as long to build.
A settings page looks simple. The mistake is treating it as one form. It is actually three separate concerns that happen to live on the same page -- and bundling them into a single component is the first thing founders regret when they need to change one of them.
The cleanest pattern is a tabbed layout: Profile, Security, and Notifications. Each tab owns its own form, its own API endpoint, and its own Zod validation schema. This keeps each component under 100 lines, avoids full-page reloads when one section saves, and lets you expand any tab later without touching the others.
Here is the routing and API shape:
/settings -- renders the Profile tab by defaultPATCH /api/users/me/profile -- display name, bio, avatar URLPATCH /api/users/me/password -- current password + new passwordPATCH /api/users/me/notifications -- boolean flags per notification typeEach endpoint reads the user from the JWT via getUserFromRequest, validates the request body with Zod using safeParse, and delegates to the user service. No business logic lives in the route.
The profile tab is the one users visit most. Name and bio are a standard React Hook Form + Zod form that patches /api/users/me/profile.
Profile photos are the part that trips people up. You need to:
/api/upload (already wired in the boilerplate)public_id in the database -- not the full URLStoring only the public_id matters because it lets you change image dimensions, format, or quality later without a database migration. The upload flow from the client is two separate calls:
// upload to Cloudinary, get back a URL
const { url } = await uploadImage(file)
// save the URL to the user profile
await putter('/api/users/me/profile', { avatarUrl: url })
Keep the upload and the profile patch as two separate API calls. If the upload fails, the profile does not change. If the profile patch fails, the file is already in Cloudinary and the user can retry without re-uploading.
The image upload docs cover the full Cloudinary setup if you need it.
Password changes need three fields: current password, new password, and confirm new password. The confirm field is frontend validation only -- use Zod .refine() to check that both fields match, and never send the confirm value to the server.
On the server, your service layer must:
passwordHashcurrentPassword against the stored hash using bcryptjs.compareIf verification fails, throw HttpError(400, 'Current password is incorrect'). Do not reveal which specific check failed -- a single generic message is correct here.
There is one edge case to plan for before you have users: accounts created via OAuth will have a null passwordHash. If a user signs in through Google and then tries to open the Security tab, the password change form will fail in a confusing way. Detect the null hash early and show a different message: your account uses social login -- set a password first if you want to log in with email. You can wire a set-password flow that skips the current-password check.
This edge case is easy to handle while you have zero users. It is painful to retrofit after the fact.
Notification preferences are the easiest tab to build and the easiest to skip -- which is a mistake. Giving users control over email frequency is one of the cheapest retention levers you have. Users who can tune their emails churn less than users who unsubscribe from everything.
Add notification preference columns to your userTable in Drizzle ORM. A sensible default set:
emailMarketing -- product updates, tips, announcements
emailDigest -- weekly activity summary
emailTransactional -- receipts, password resets (always on)
The Notifications tab renders a list of toggles using shadcn Switch components. One PATCH /api/users/me/notifications call saves all flags at once when the user clicks Save.
Lock emailTransactional to always-on in both the UI and the service layer. Show the toggle as checked and disabled. Security emails and receipts must reach users regardless of their marketing preferences -- this is not optional.
If you are starting from the Next.js SaaS boilerplate, the infrastructure is already in place:
getUserFromRequest is already wired; call it at the top of each PATCH handleruserTable, run npm run db:generate && npm run db:migrate/api/upload route is already protected and returns { url }useFormTabs, Form, Input, Switch, and Button are all available without installing anything newThe settings page does not need a new module. Add three service methods to the existing user module: updateProfile, changePassword, and updateNotifications. Three endpoints. Three forms. One tab component per concern.
Not all three tabs have the same urgency:
The settings page is not glamorous. It does not appear in demo videos or on your landing page. But it is one of the first things users look for after they sign up -- and finding nothing is the kind of small friction that stacks up into churn.
If you want the foundation without building it yourself, the boilerplate has auth, Cloudinary uploads, Drizzle ORM, and all the UI components ready on day one. Get started here and spend your time on the features that make your product unique.