Feature spec
BeginnerCategory
forms-data
Build with AI
4-8 hours with Lovable or V0 + Supabase
Custom build
3-5 days custom dev
Running cost
$0/mo up to 1K users; $0-25/mo at 10K users
Works on
Everything it takes to ship Profile Customization — parts, prompts, and real costs.
Profile customization needs an avatar uploader (react-dropzone + react-image-crop), a profile form (react-hook-form + zod), and a `profiles` table in Supabase with a public SELECT policy for profile pages. With Lovable or V0 you can ship a working profile feature in 4–8 hours for $0/month on the free tier. The only tricky parts are the username uniqueness race condition and the localStorage SSR crash on theme preferences.
What Profile Customization Actually Is
Profile customization is the layer that lets users make your app feel like theirs: pick a display name and username, write a bio, upload an avatar with crop-before-save, add social links, and set a color theme that sticks across sessions. For most apps it's two pages — the edit form (authenticated) and the public profile at /u/username (visible without login). The product decisions that matter: whether username is locked after creation, which social links to allow (open-ended vs constrained to specific platforms), and whether the profile is always public or can be set private. These choices shape the data model more than the form itself.
What users consider table stakes in 2026
- Avatar upload with crop-before-save so users see exactly what their profile photo will look like before it's stored
- Real-time username availability check with a green checkmark or red X appearing as the user types (debounced at 300ms)
- Character counter on the bio field with a hard 160-character limit enforced both in the UI and on the server
- Social/portfolio links with protocol validation — users shouldn't be able to save 'linkedin.com/in/user' without https://
- Theme or accent color preference that applies immediately on save without a page reload
- Public profile URL at /u/[username] that works without requiring visitors to log in
Anatomy of the Feature
Six components, most of which AI tools build correctly in a single prompt. The avatar crop step and the username availability debounce are where first builds most often fall short.
Avatar uploader
UIreact-dropzone handles drag-or-click file selection with accept restrictions (image/jpeg, image/png, image/webp, max 2MB). After file selection, ReactCrop (react-image-crop) opens in a modal for in-browser cropping before any upload happens. The cropped canvas blob is uploaded to Supabase Storage's `avatars` bucket, and the returned public URL is stored in `profiles.avatar_url`.
Note: The crop step must happen before upload — not after. AI tools sometimes wire dropzone → upload → crop, which means users can't crop already-uploaded images. The correct flow is: select file → open crop modal → confirm crop → upload cropped blob → update profile row.
Profile form
UIreact-hook-form with a zod schema managing five fields: display_name (required, 2–50 chars, trimmed), username (lowercase alphanumeric + hyphens only, 3–30 chars, unique), bio (0–160 chars with live character counter), website_url (optional, must start with https://), and social_links (JSONB array of {platform: string, url: string} pairs). shadcn/ui Input, Textarea, and Badge components for consistent styling. Unsaved changes trigger a beforeunload warning via a useEffect.
Note: Validate username client-side with /^[a-z0-9-]+$/ before hitting the availability endpoint — this prevents server calls for invalid patterns and gives instant feedback.
Username availability checker
BackendA Supabase Edge Function or Next.js API Route that accepts a username query parameter, queries the `profiles` table for an existing match, and returns `{ available: boolean }`. Called via a debounced fetch triggered 300ms after the user stops typing. Rate-limited to 10 requests per 10 seconds per IP using Upstash Ratelimit (optional at low scale).
Note: The availability check is optimistic — it's a UX affordance, not a guarantee. The Postgres UNIQUE constraint on username is the real enforcer. The form submit handler must catch error code 23505 and display 'Username just taken — try another' as a fallback.
Theme preference store
DataA `preferences` JSONB column in the `profiles` table stores the user's UI preferences as `{ accent_color: '#6366f1', font_size: 'md', density: 'comfortable' }`. On save, the profile upsert writes the new preferences, and a React context updates CSS custom properties on the root element immediately — no page reload. Theme reads on initial load use a useEffect to avoid SSR crashes from localStorage access.
Note: If theme preference needs to persist before the user logs in (for guest visitors), mirror it to localStorage as a fallback and sync to the database on next login.
Public profile renderer
UIA Next.js server component at /u/[username] that queries the `profiles` table via the Supabase anon key with no auth header. Renders avatar (with initials fallback if null), display_name, bio, and social links. Returns a proper 404 response if the username doesn't exist. Uses generateMetadata() to set open graph tags for profile sharing.
Note: This page requires a SELECT policy on `profiles` with USING (true) — the default auth.uid() = id policy blocks all unauthenticated reads and causes the public profile to 404 for every visitor.
Follow/connection count
DataAn optional Supabase COUNT query on a `follows` table (follower_id, following_id, created_at) joined at render time on the public profile page. Displayed as '142 followers · 38 following'. At MVP, this can be a simple counter in the `profiles` table that increments/decrements on follow/unfollow, trading accuracy for query simplicity.
Note: Defer this to phase 2 unless social following is a core product mechanic — the profiles feature ships usefully without it.
The data model
One table extending Supabase Auth with a public SELECT policy for profile pages and restricted UPDATE. Run this in the Supabase SQL editor.
1create table public.profiles (2 id uuid primary key references auth.users(id) on delete cascade,3 username text unique not null,4 display_name text,5 bio text check (char_length(bio) <= 160),6 avatar_url text,7 website_url text,8 social_links jsonb not null default '[]'::jsonb,9 preferences jsonb not null default '{}'::jsonb,10 updated_at timestamptz not null default now()11);1213alter table public.profiles enable row level security;1415-- Public profiles are readable by anyone (including anonymous visitors)16create policy "Profiles are publicly readable"17 on public.profiles for select18 using (true);1920-- Users can only update their own profile21create policy "Users update own profile"22 on public.profiles for update23 using (auth.uid() = id)24 with check (auth.uid() = id);2526-- Users can insert their own profile (on first setup)27create policy "Users insert own profile"28 on public.profiles for insert29 with check (auth.uid() = id);3031-- Username lookup index for availability checks and profile page routing32create unique index profiles_username_idx on public.profiles (username);3334-- Trigger to auto-create a profile row on new user signup35create or replace function public.handle_new_user()36returns trigger37language plpgsql security definer set search_path = public38as $$39begin40 insert into public.profiles (id, username, display_name, avatar_url)41 values (42 new.id,43 lower(split_part(new.email, '@', 1)) || '_' || substr(new.id::text, 1, 4),44 coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)),45 new.raw_user_meta_data->>'avatar_url'46 )47 on conflict (id) do nothing;48 return new;49end;50$$;5152create trigger on_auth_user_created53 after insert on auth.users54 for each row execute procedure public.handle_new_user();Heads up: The trigger auto-creates a profile on signup using the email prefix plus 4 random chars as a draft username — users should be prompted to choose a permanent username on first login. The CHECK constraint on bio enforces the 160-character limit at the database level as a backstop to client-side validation.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
The fastest all-in-one path: Lovable's Supabase integration auto-creates the profiles table and Storage bucket, handles avatar upload via the connector, and scaffolds the public profile page — ideal for getting this live today.
Step by step
- 1Create a new Lovable project, connect Lovable Cloud, and verify that Supabase Auth is enabled in the Cloud tab — the profile trigger needs auth.users to exist
- 2Paste the prompt below; Lovable will generate the profile edit page, avatar uploader, username availability checker, and public profile route in Agent Mode
- 3If the crop step is missing (file uploads directly without cropping), add a follow-up prompt: 'Add react-image-crop to the avatar upload flow — the crop modal should open after file selection and before the Supabase Storage upload'
- 4Test the username availability endpoint by typing an existing username and confirming the red X appears within 300ms; type a unique username and confirm the green checkmark
- 5Open the public profile URL (/u/your-username) in an incognito window to verify it loads without authentication
- 6Click Publish, then test avatar upload and crop on the live URL — file input may be sandboxed in the Lovable preview
Build a profile customization feature with Supabase. Create a `profiles` table extending auth.users: id UUID (references auth.users), username TEXT UNIQUE, display_name TEXT, bio TEXT (max 160 chars), avatar_url TEXT, website_url TEXT, social_links JSONB (array of {platform, url} objects), preferences JSONB (accent_color, font_size, density), updated_at TIMESTAMPTZ. RLS: SELECT unrestricted (public profiles); INSERT/UPDATE only where auth.uid() = id. Add a trigger that auto-creates a profile row on new user signup using the email prefix as a draft username. Profile edit page (/settings/profile): avatar upload using react-dropzone (accept: JPEG/PNG/WebP, max 2MB) with react-image-crop modal that opens after file selection so users crop before uploading to Supabase Storage `avatars` bucket. Profile form using react-hook-form + zod: display_name (2-50 chars), username (lowercase alphanumeric + hyphens, 3-30 chars), bio (0-160 chars with character counter), website_url (https:// validation), social_links (add/remove rows with platform + URL). Username field: debounced availability check (300ms) calling a Supabase Edge Function or API route; show green checkmark if available, red X if taken. On submit: upsert profile row. Initials avatar fallback: show user's initials in a colored circle when avatar_url is null (color derived from user id hash). Public profile page at /u/[username]: server-rendered, shows avatar, display_name, bio, website, social links; returns 404 if username not found. UI states: unsaved changes warning on navigate-away (beforeunload), save success toast, validation error inline per field, saving skeleton while upsert is pending.Where this path bites
- react-image-crop wiring (crop before upload, not after) often requires a follow-up prompt to implement in the correct sequence
- Username availability debounce is sometimes implemented as a blocking synchronous fetch rather than a debounced async call — test by typing quickly and confirming the endpoint isn't called on every keystroke
- OAuth-based avatar pre-population from Google/GitHub profile photos requires explicit prompting and may show the wrong redirect URL inside the Lovable preview iframe
Third-party services you'll need
Profile customization runs almost entirely on free libraries. Storage costs are the only variable that scales.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | Auth + PostgreSQL for the profiles table + Storage for the avatars bucket | 500MB DB, 1GB Storage (approx 10K avatars at typical sizes) | Pro $25/mo (8GB DB, 100GB Storage) |
| react-image-crop | In-browser avatar cropping before upload — no server-side image processing needed | Fully free (MIT) | Free, open-source |
| react-dropzone | Drag-and-drop or click-to-browse file selection for avatar upload | Fully free (MIT) | Free, open-source |
| Upstash Ratelimit (optional) | Rate-limit the username availability endpoint to prevent enumeration attacks | 10K requests/day free | Pro $10/mo (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier covers profile storage and avatar files at this scale. All UI libraries are free. Username availability endpoint stays within Upstash free tier.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Avatar URL not updating in the UI after upload
Symptom: Supabase Storage returns the public URL synchronously after upload, but updating `profiles.avatar_url` is a separate database upsert. If the avatar display component reads from a React state variable that's updated before the profile upsert completes, the new avatar flickers in then reverts to the old one on the next read. If the component reads directly from Supabase on every render, it may show the old value until a refetch.
Fix: Await the Storage upload, extract the public URL, then await the profile upsert, and only update local React state after both promises resolve. Use a loading state that disables the avatar display during the update sequence. Never fire the state update between the two async operations.
Username uniqueness race condition at the database level
Symptom: Two users simultaneously check the same username — both see the green 'available' checkmark because neither has submitted yet. Both submit their profiles within milliseconds of each other. The first upsert succeeds; the second hits Postgres UNIQUE constraint violation and returns error code 23505. If the form submit handler doesn't catch this specific error code, the user sees a generic 'Something went wrong' message with no guidance.
Fix: In the profile upsert handler, catch error code 23505 specifically and display 'That username was just taken — please choose another.' The availability check is only a UX signal; the database constraint is the final enforcer. This race is rare but certain to happen once you have more than a few hundred active users.
localStorage SSR crash on theme preference reads
Symptom: V0-generated theme providers commonly read from localStorage to initialize the accent color preference. During Next.js server-side rendering, localStorage is undefined — Node.js doesn't have it. The build crashes with 'ReferenceError: localStorage is not defined' on the first SSR render. This error only appears in production builds; the V0 preview sometimes masks it.
Fix: Wrap all localStorage reads in a useEffect (which runs only in the browser) or add an `if (typeof window === 'undefined') return defaultPreferences` guard at the top of the function. For the ThemeProvider component, use next/dynamic with ssr: false to prevent SSR entirely. Never access localStorage during the synchronous render phase.
Public profile page returns 404 for all visitors
Symptom: The default Supabase RLS pattern restricts all reads to `WHERE auth.uid() = id`. When an unauthenticated visitor loads /u/username, the Supabase query with the anon key finds zero rows (RLS blocks the read) and the page renders as a 404. The data exists in the database — the policy is silently filtering it out.
Fix: Add a SELECT policy with `USING (true)` on the `profiles` table: `CREATE POLICY 'Profiles are publicly readable' ON public.profiles FOR SELECT USING (true)`. This allows anyone — including anonymous visitors — to read profile rows. Keep INSERT and UPDATE restricted to `auth.uid() = id` so only the owner can modify their profile.
Best practices
Always crop before upload — let users preview exactly what their profile photo will look like at avatar dimensions before it's stored in Supabase, not after
Treat the username availability check as a UX hint only — always catch Postgres error 23505 in the submit handler and show a human-readable 'username just taken' message as the final guard
Derive the initials avatar color from the user's ID (not display name) so the color stays consistent even when the display name changes — a hash of the first 8 chars of the UUID maps to a stable hue
Store theme preferences in the `profiles.preferences` JSONB column, not in localStorage alone — localStorage is lost when users switch browsers, clear data, or log in on a new device
Use the Supabase trigger pattern to auto-create a profile row on signup — waiting for users to create their own profile row leads to null reference errors on every page that expects a profile to exist
Cache avatar URLs at the CDN level: Supabase Storage public URLs are stable and change only on re-upload, so a 1-hour cache-control header eliminates repeat egress costs on popular profiles
Validate social link URLs with a regex that requires the https:// protocol — bare domain links and javascript: URLs both need to be rejected at the form validation layer before reaching the database
When You Need Custom Development
Standard profile customization — avatar, username, bio, links, theme — is well within reach of an AI-generated build. These signals indicate the profile has grown beyond that scope:
- Profile IS the core product: a creator marketplace, professional directory, or platform where the profile page layout, SEO optimization, and social sharing are primary product differentiators
- Enterprise SSO requirement: profile fields must sync from an identity provider (Okta, Azure AD, Google Workspace SCIM) rather than being user-entered
- Custom avatar processing pipeline: background removal, AI-generated profile pictures, or NSFW content moderation before storage — none of which react-image-crop handles
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
How do I let users pick a username that's unique across the app?
Add a UNIQUE constraint on the `profiles.username` column in Postgres — this is the actual uniqueness enforcer. Layer a client-side availability check on top: call a debounced API route 300ms after the user stops typing, query for existing rows with that username, and return { available: boolean }. In the form submit handler, catch Postgres error code 23505 and display 'Username just taken — try another' as the fallback for the rare race condition where two users check the same username simultaneously.
Can I make user profiles public without requiring login to view them?
Yes — add a Supabase RLS SELECT policy with `USING (true)` on the `profiles` table. This allows the anon key (used by unauthenticated visitors) to read profile rows. The public profile page at /u/[username] uses the Supabase anon client — no auth header — and renders in Next.js as a server component for fast initial load. Keep INSERT and UPDATE restricted to auth.uid() = id so only the owner can modify their profile.
How do I add an avatar with crop-before-upload?
The correct flow: react-dropzone handles file selection → open a modal with react-image-crop (ReactCrop component) → user drags to set crop bounds → on confirm, call canvas.toBlob() to get the cropped image as a Blob → upload that Blob to Supabase Storage → store the returned public URL in profiles.avatar_url. The critical detail is crop happens before upload, not after. AI tools sometimes wire this in the wrong order — check your implementation's flow if the crop UI opens after an image is already stored.
How do I save a user's color theme preference so it persists after logout?
Store the preference in the `profiles.preferences` JSONB column as `{ accent_color: '#6366f1' }`. On login, read it from Supabase and apply it to the document root as a CSS custom property (`document.documentElement.style.setProperty('--accent', color)`). For instant apply without page reload, update the CSS property in the profile save handler before the upsert resolves. Mirror to localStorage as a fallback so the preference loads on initial page render before the Supabase fetch completes — but always treat the database value as the source of truth.
Can users have custom profile URLs like app.com/username?
Yes — add a Next.js dynamic route at `/u/[username]`. The page reads the username parameter, queries `profiles WHERE username = [username]`, and renders the profile. Return Next.js's `notFound()` if no row exists. For SEO, call `generateMetadata()` to set the page title, description, and Open Graph image from the profile data. The username must be stored in Postgres with a UNIQUE index so routing always resolves to exactly one profile.
How do I show a placeholder avatar with the user's initials if they haven't uploaded a photo?
When avatar_url is null, render a div with a background color derived from the user's ID and display the first two characters of display_name or username as initials. To derive a stable color: convert the first 8 characters of the user UUID to a number via charCodeAt(), modulo 360, and use it as an HSL hue (e.g., `hsl(${hue}, 60%, 45%)`). This gives each user a unique but consistent color across sessions and devices.
What's the right Supabase table structure for user profiles?
One `profiles` table with id as a foreign key to auth.users(id), not a separate users table. Store username, display_name, bio, avatar_url, website_url, social_links as JSONB, and preferences as JSONB in the same row. Add a trigger on auth.users INSERT to auto-create the profile row on signup using the email prefix as a draft username. This avoids null reference errors on every page that expects a profile — the row always exists by the time the user first interacts with the app.
Need this feature production-ready?
RapidDev builds profile customization into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.