# How to Add Profile Customization to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

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.

## 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** (ui): react-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`.
- **Profile form** (ui): react-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.
- **Username availability checker** (backend): A 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).
- **Theme preference store** (data): A `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.
- **Public profile renderer** (ui): A 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.
- **Follow/connection count** (data): An 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.

## 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.

```sql
create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  username text unique not null,
  display_name text,
  bio text check (char_length(bio) <= 160),
  avatar_url text,
  website_url text,
  social_links jsonb not null default '[]'::jsonb,
  preferences jsonb not null default '{}'::jsonb,
  updated_at timestamptz not null default now()
);

alter table public.profiles enable row level security;

-- Public profiles are readable by anyone (including anonymous visitors)
create policy "Profiles are publicly readable"
  on public.profiles for select
  using (true);

-- Users can only update their own profile
create policy "Users update own profile"
  on public.profiles for update
  using (auth.uid() = id)
  with check (auth.uid() = id);

-- Users can insert their own profile (on first setup)
create policy "Users insert own profile"
  on public.profiles for insert
  with check (auth.uid() = id);

-- Username lookup index for availability checks and profile page routing
create unique index profiles_username_idx on public.profiles (username);

-- Trigger to auto-create a profile row on new user signup
create or replace function public.handle_new_user()
returns trigger
language plpgsql security definer set search_path = public
as $$
begin
  insert into public.profiles (id, username, display_name, avatar_url)
  values (
    new.id,
    lower(split_part(new.email, '@', 1)) || '_' || substr(new.id::text, 1, 4),
    coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)),
    new.raw_user_meta_data->>'avatar_url'
  )
  on conflict (id) do nothing;
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();
```

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 paths

### Lovable — fit 5/10, 4-6 hours

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.

1. Create 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
2. Paste the prompt below; Lovable will generate the profile edit page, avatar uploader, username availability checker, and public profile route in Agent Mode
3. If 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'
4. Test 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
5. Open the public profile URL (/u/your-username) in an incognito window to verify it loads without authentication
6. Click Publish, then test avatar upload and crop on the live URL — file input may be sandboxed in the Lovable preview

Starter prompt:

```
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.
```

Limitations:

- 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

### V0 — fit 4/10, 4-8 hours

V0 generates clean Next.js Server Actions for the profile upsert and a well-structured public profile page — best when profile customization is part of a larger Next.js app where you want maintainable, extensible component code.

1. Prompt V0 with the spec below; it will generate a ProfileForm component, AvatarUploader component, UsernameChecker component, and the public profile page at /u/[username]
2. Add Supabase environment variables in the Vars panel: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY for the username availability endpoint
3. Run the SQL schema from this page in the Supabase SQL editor; also create the `avatars` bucket in Supabase Storage Dashboard with public access disabled
4. If you see 'localStorage is not defined' during the V0 preview, add a follow-up prompt: 'Wrap all localStorage reads for theme preferences in useEffect or add typeof window !== undefined guards; use next/dynamic with ssr:false for the ThemeProvider'
5. Test the public profile page at /u/[username] in the V0 preview to confirm it renders without auth headers

Starter prompt:

```
Build a profile customization feature in Next.js App Router with Supabase and shadcn/ui. Supabase table: `profiles` (id UUID references auth.users, username TEXT UNIQUE NOT NULL, display_name TEXT, bio TEXT, avatar_url TEXT, website_url TEXT, social_links JSONB DEFAULT '[]', preferences JSONB DEFAULT '{}', updated_at TIMESTAMPTZ). RLS: SELECT with USING (true) for public reads; INSERT/UPDATE with auth.uid() = id. ProfileForm component (client): react-hook-form + zod schema validating display_name (2-50 chars, required), username (regex /^[a-z0-9-]+$/, 3-30 chars), bio (max 160 chars with live counter), website_url (optional, must start with https://), social_links array (add/remove rows). Username field: call /api/username-check?username= with 300ms debounce using useCallback; show green check or red X. Avatar: AvatarUploader client component using react-dropzone (accept image/*) + react-image-crop ReactCrop component that opens after file selection; on crop confirm, upload cropped canvas blob to Supabase Storage `avatars` bucket; update profiles.avatar_url. Initials fallback: derive color from user id char codes, display 2-letter initials in a circle. Server Action `updateProfile` for upsert with error handling (catch 23505 for username taken). Public profile page at /u/[username]: Next.js server component using Supabase anon key; generateMetadata for OG tags; 404 notFound() if username not found. Theme preferences: store in profiles.preferences JSONB; apply accent_color as CSS custom property on root via React context; wrap localStorage fallback reads in useEffect only (never SSR). UI states: unsaved changes warning via useBeforeUnload hook, save success toast, inline field errors, save button loading spinner.
```

Limitations:

- Supabase Storage avatars bucket must be manually created in the Supabase Dashboard — V0 does not auto-provision buckets
- localStorage SSR crash is the most common V0 error on this feature — the `typeof window !== 'undefined'` guard or next/dynamic with ssr:false fix is almost always needed
- No auto-provisioning of the Supabase database — run the SQL schema manually before testing

### Custom — fit 3/10, 3-5 days

Custom development is justified only when the profile IS the core product — a creator marketplace, professional directory, or platform where profile layout and SEO-optimized profile pages are a primary differentiator.

1. Build the profile as a creator-first layout: portfolio sections, featured work gallery, custom background images, and pinned links — none of which the standard form pattern handles well
2. Add custom avatar processing: background removal via a Supabase Edge Function calling Remove.bg API, NSFW content moderation via AWS Rekognition before storage, AI-generated profile picture suggestions
3. Build SEO-optimized profile pages with full JSON-LD Person schema, canonical URLs, and sitemap entries generated from the profiles table — critical for professional directory use cases
4. Implement enterprise SSO sync: profile fields (name, title, department, photo) populated from Okta or Azure AD via SCIM, with user-editable fields limited to bio and custom links

Limitations:

- Higher upfront investment for what is largely a well-solved CRUD pattern at the standard profile level
- Most of the custom work — avatar processing, SEO pages, SSO sync — is only justified when profile is the product, not a supporting feature

## Gotchas

- **Avatar URL not updating in the UI after upload** — 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** — 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** — 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** — 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

## 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.

---

Source: https://www.rapidevelopers.com/app-features/profile-customization
© RapidDev — https://www.rapidevelopers.com/app-features/profile-customization
