Feature spec
BeginnerCategory
personalization-ux
Build with AI
2–4 hours with Lovable or FlutterFlow
Custom build
3–5 days custom dev
Running cost
$0–$25/mo up to 10K users
Works on
Everything it takes to ship User Profiles — parts, prompts, and real costs.
A user profile needs four pieces: an auth-linked database row, a form for editable fields, avatar upload to Supabase Storage, and Row-Level Security policies so owners can edit but strangers can only read public fields. With Lovable or V0 you can ship the full feature in 2–4 hours for $0–$25/month — Supabase free tier covers auth and storage at up to 50K users.
What a User Profile Feature Actually Is
A user profile is the data layer that puts a human face on an auth token. It stores the name, photo, and preferences users associate with themselves — and exposes the right slice of that data to the right audience. The architecture decision that matters most is not which fields to include, but who can see what: a public profile URL anyone can browse versus private fields like email that only the owner sees. This maps directly to Row-Level Security policies in Supabase: one SELECT policy returns all public columns to any authenticated user, while UPDATE is locked to the row owner. Getting this right on day one prevents data leakage and the painful RLS retrofit that breaks half the app.
What users consider table stakes in 2026
- Editable display name, bio, and avatar with a live crop-to-circle preview before saving
- Instant save with a visible success toast and a fallback error message if the upload fails
- Public profile URL the user can copy and share — shareable with non-logged-in viewers
- Private fields (email, phone) hidden from the public view regardless of query path
- Avatar upload with a clear 5MB size limit communicated before the file picker opens
- Account deletion accessible within profile settings, not buried in a support email
Anatomy of the Feature
Seven components make up a production user profile. AI tools handle the form and Supabase persistence reliably on the first prompt. The avatar upload pipeline and privacy controls need explicit specification to avoid the most common failure modes.
Avatar Uploader
UIFile input powered by react-dropzone (web) or image_picker (Flutter). Accepts image files, enforces a 5MB size limit client-side, and runs client-side crop via react-image-crop on web or the image_cropper package on Flutter before upload. Writes to Supabase Storage at the path user/{user_id}/avatar.jpg so Storage RLS policies can reference the folder name to verify ownership.
Note: Always crop before upload — sending a 4MB RAW phone photo to Storage wastes bandwidth and storage quota. The crop step also enforces a square aspect ratio, making the later circle clip predictable.
Profile Form
UIreact-hook-form with zod validation on web: display_name (required, max 50 chars), bio (max 200 chars), website (valid URL or empty). shadcn/ui Input and Textarea components. Flutter equivalent: TextFormField widgets with custom validators inside a Form widget.
Note: Bio must be a Textarea, not an Input. AI tools routinely generate an Input for bio, which strips newlines and makes multi-paragraph bios appear as one long line.
Profile Header
UIRenders avatar, display_name, join date, and optional follower counts if the app is social. Data fetched from the profiles table and cached in React Query (web) or Riverpod StateNotifier (Flutter). On public profile pages this component must read only the columns the SELECT policy exposes — never query with the service role key from the client.
Note: Keep the header component stateless and pass profile data as props so it works for both the owner's edit view and the public read-only view without conditional logic inside the component.
Privacy Controls
UIToggle group for per-field visibility (email, phone). Writes to is_email_public boolean on the profiles row. On web: Radix UI Switch components. On Flutter: Switch widgets inside a ListTile. The RLS SELECT policy must reference this column to conditionally expose the field.
Note: Do not enforce privacy purely in the frontend. If the SELECT policy returns the email column unconditionally, a user inspecting the API response will see it regardless of the UI toggle.
Profile API Layer
BackendSupabase Row-Level Security policies govern all access. SELECT policy: authenticated users see all public columns (display_name, bio, avatar_url, website); the owner additionally sees private columns (email, is_email_public). UPDATE policy: restricted to rows where auth.uid() = id. An optional Edge Function handles avatar resize via the sharp library (Deno-compatible) to generate thumbnails without the client doing layout computation.
Note: Write the RLS policies in the same prompt as the table creation. Generating them in a follow-up prompt is the most reliable way to end up with a table that has RLS enabled but no policies — blocking all access.
Profiles Table
DataSupabase PostgreSQL table: id uuid (primary key, foreign key to auth.users), display_name text not null, bio text, avatar_url text, website text, is_email_public boolean default false, created_at timestamptz default now(), updated_at timestamptz default now(). A database trigger on auth.users INSERT auto-creates a default profiles row so the app never reads a null profile for a new user.
Image Transform Service
ServiceSupabase Storage image transformations resize and crop avatar images on request via URL parameters: ?width=200&height=200&resize=cover. Produces consistent thumbnails without a separate image CDN. Included at no extra cost on the Supabase Pro plan.
Note: Store only the base Storage path (user/{id}/avatar.jpg) in the database, never the full URL with the Supabase project domain. Construct the full URL at render time from the SUPABASE_URL env var — this prevents mass broken-image failures if the project URL ever changes.
The data model
One table links to auth.users and carries all profile fields. RLS policies separate public read from owner-only write. Run this in the Supabase SQL Editor — all at once so the policies are created alongside the table.
1create table public.profiles (2 id uuid primary key references auth.users(id) on delete cascade,3 display_name text not null,4 bio text,5 avatar_url text,6 website text,7 is_email_public boolean not null default false,8 created_at timestamptz not null default now(),9 updated_at timestamptz not null default now()10);1112alter table public.profiles enable row level security;1314-- Any authenticated user can read public fields15create policy "Public profiles are viewable by authenticated users"16 on public.profiles for select17 to authenticated18 using (true);1920-- Only the owner can update their own profile21create policy "Users can update own profile"22 on public.profiles for update23 to authenticated24 using (auth.uid() = id)25 with check (auth.uid() = id);2627-- Auto-create a profile row when a new auth user signs up28create or replace function public.handle_new_user()29returns trigger language plpgsql security definer set search_path = public as $$30begin31 insert into public.profiles (id, display_name)32 values (new.id, coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)));33 return new;34end;35$$;3637create trigger on_auth_user_created38 after insert on auth.users39 for each row execute procedure public.handle_new_user();4041-- Index for fast profile lookups by id (primary key covers this, but explicit for clarity)42create index profiles_updated_at_idx on public.profiles (updated_at desc);Heads up: The trigger pre-populates display_name from the OAuth full_name or email prefix so new users never see a blank profile. The is_email_public flag is false by default — email is never exposed unless the user explicitly enables it.
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.
Lovable auto-creates the profiles table and RLS via its Supabase integration. Avatar upload to Storage is a one-prompt addition. Best choice when you want the full feature including auth in one session.
Step by step
- 1Create a new Lovable project with Lovable Cloud enabled so Supabase is provisioned automatically
- 2Paste the prompt below in Agent Mode — Lovable will generate the profiles table, RLS policies, the auth trigger, the profile edit form, and the avatar uploader
- 3After generation, open the Cloud tab and navigate to Storage to verify the 'avatars' bucket was created with the correct RLS INSERT policy
- 4Preview the profile page in the Lovable mobile preview by scanning the QR code — avatar upload requires HTTPS to work
- 5Click Publish and test the public profile URL by copying the shareable link and opening it in an incognito window
Build a user profile feature on Supabase. Create a profiles table: id uuid primary key references auth.users, display_name text not null, bio text, avatar_url text, website text, is_email_public boolean default false, created_at and updated_at timestamptz. Enable RLS: any authenticated user can SELECT, only the owner (auth.uid() = id) can UPDATE. Add a database trigger that auto-creates a profiles row with a default display_name on auth.users INSERT. Create a profile edit page at /profile with: avatar upload using react-dropzone with client-side crop to a circle using react-image-crop (max 5MB, show error if exceeded), form fields for display_name (required, max 50 chars), bio (Textarea, max 200 chars), website (valid URL or empty) using react-hook-form and zod validation. Show a loading skeleton while profile data fetches. On save, show a success toast; on upload error, show a retry button. Upload avatar to Supabase Storage bucket 'avatars' at path user/{user_id}/avatar.jpg. Also create a public profile page at /profile/{user_id} that shows only public fields — never show email on the public view. Store only the Storage path in the database, not the full URL.Where this path bites
- Avatar crop UI often needs a second prompt to refine the circle overlay positioning and preview sizing
- Lovable Cloud managed Supabase is not visible in the Supabase dashboard — to inspect the profiles table directly, connect your own Supabase project via the Cloud tab
Third-party services you'll need
User profiles rely entirely on Supabase services — no third-party APIs are required for the core feature:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase Storage | Avatar image hosting with CDN delivery and RLS-gated upload | 1GB storage, 2GB bandwidth/mo | Pro $25/mo includes 100GB storage |
| Supabase Auth | User identity that the profiles table foreign-keys to | Up to 50K monthly active users | Pro $25/mo (included); $0.00325/MAU above 50K (approx) |
| Supabase Image Transforms | On-the-fly avatar thumbnail generation via URL params | Not available on free tier | Included in Pro $25/mo plan (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 storage, auth, and bandwidth at this scale. Avatar CDN egress for 100 users is negligible.
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.
Updated profile data doesn't reflect immediately after save
Symptom: React Query or SWR caches the profile data from the initial fetch. After a successful Supabase UPDATE, the cache still holds the old values and the page shows stale data until the next full refresh. This looks like a save failure to users even though the database was updated correctly.
Fix: Call queryClient.invalidateQueries(['profile', userId]) immediately after the successful mutation response. Alternatively, use Supabase Realtime to subscribe to the profiles row and apply updates automatically — this also handles multi-tab consistency.
Avatar upload works in Lovable preview but returns 403 on the published URL
Symptom: Supabase Storage buckets default to private. When Lovable generates the Storage upload code, it creates the bucket but not the INSERT RLS policy for it. The upload succeeds in the Lovable environment (which uses the service role key internally) but fails on the public app that uses the anon key.
Fix: In the Supabase dashboard under Storage → Policies, add an INSERT policy on the 'avatars' bucket: allow insert for authenticated where (storage.foldername(name))[1] = auth.uid()::text. This ensures users can only upload to their own subfolder.
Bio text with line breaks saves as a single line
Symptom: V0 and Lovable sometimes generate a shadcn/ui Input component for the bio field instead of a Textarea. Input renders as a single-line text box, so newlines are stripped on submit. The database receives the text correctly from any Textarea, but the mismatched component loses the formatting before it even reaches the form handler.
Fix: Replace the shadcn/ui Input with Textarea for any multiline field. Set rows={4} as a minimum. On the display side, add the CSS class whitespace-pre-wrap to preserve line breaks when rendering the stored bio.
Display name shows as null for new users who skip onboarding
Symptom: The profiles row only exists if it was created at signup. If the auth trigger was not set up, or if users were imported via a bulk insert to auth.users without the trigger firing, the profiles table has no row for them. Every component that reads display_name will receive null and render nothing or crash.
Fix: Add the database trigger shown in the data model section above. For existing users with missing rows, run a one-time INSERT: INSERT INTO profiles (id, display_name) SELECT id, split_part(email, '@', 1) FROM auth.users WHERE id NOT IN (SELECT id FROM profiles). Handle null gracefully in the UI with a fallback to the user's email prefix until they set a name.
Avatar URL breaks after connecting a custom domain to Supabase
Symptom: If avatar_url was saved as a full absolute URL (https://xyz.supabase.co/storage/v1/object/public/avatars/user/123/avatar.jpg), every avatar breaks the moment the project URL changes. This affects all apps that linked a custom domain or migrated to a new Supabase project.
Fix: Store only the Storage path (user/{id}/avatar.jpg) in the database column, never the full URL. Construct the full URL at render time: const avatarUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/avatars/${profile.avatar_url}`. This way the base URL is always current.
Best practices
Store only the Storage path in the avatar_url column, never the full Supabase URL — construct it at render time from the env var
Always add the auth trigger that auto-creates a profiles row on signup; never assume the row exists before reading it
Enforce the 5MB avatar size limit client-side before the upload begins, not server-side after consuming bandwidth
Write SELECT and UPDATE RLS policies in the same prompt as the table — policies created in a follow-up are the most common source of 'table has RLS but no policies' lockouts
Separate the public profile page (Server Component, read-only) from the edit page (client, auth-gated) at the route level so the public view never accidentally exposes private columns
Cache avatar images aggressively at the CDN layer — avatar files change rarely but are fetched on every profile view
Implement the is_email_public flag in the RLS SELECT policy, not just the frontend — policy-level enforcement is the only guarantee that private fields stay private
When You Need Custom Development
AI tools handle the standard edit, view, and avatar pipeline well. A few product decisions push the profile feature past what Lovable or FlutterFlow can generate reliably:
- Profile requires real-time identity verification badges from third-party providers such as Persona or Stripe Identity — these add OAuth flows, webhook listeners, and badge state machines not generated by AI tools
- Social graph features like follower/following counts, mutual connections, and block lists compound the table relationships and query complexity far beyond simple CRUD
- Profile data must sync bidirectionally with an external CRM (Salesforce, HubSpot) on every update — webhook handlers and conflict resolution for two-way sync require custom server infrastructure
- Multi-tenant apps where profile fields and visibility rules differ per organization need a dynamic schema approach that AI tools cannot generate correctly in a single prompt
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
Can users have multiple profiles under one account?
Yes, but it requires reworking the data model. The standard profiles table uses the auth user id as the primary key, enforcing one profile per user. For multiple profiles, add a profiles table with its own uuid primary key and a user_id foreign key (not primary key), then add a RLS policy filtering on user_id. You would also need a UI for profile switching and a way to store the 'active profile' in session state.
How do I make profile pages publicly accessible without requiring login?
Change the SELECT RLS policy on the profiles table to allow access for the 'anon' role in addition to 'authenticated'. The policy condition remains true (all public rows visible). Ensure the Supabase client in your public profile page is initialized with the anon key only. Fields like email that should remain private must be excluded at the query level — select only the columns you intend to show.
What is the best way to handle avatar upload size limits?
Enforce the limit in three places: client-side before upload (check file.size < 5 * 1024 * 1024 and show an inline error immediately), in the Supabase Storage policy (the dashboard lets you set a max file size per bucket), and optionally in an Edge Function if you are resizing on upload. Client-side enforcement gives instant feedback; the Storage policy is the security backstop that prevents circumvention.
How do I add social links like Twitter and LinkedIn to the profile?
Add text columns to the profiles table for each platform (twitter_url text, linkedin_url text, etc.) or use a single social_links jsonb column if the list might grow. Validate URL format with zod's z.string().url().optional() in the form. Display the links as icon buttons using the platform's brand icon from a library like react-icons, with target='_blank' and rel='noopener noreferrer' on the anchor.
Can I let users choose a custom profile URL or username?
Yes. Add a username text column with a unique constraint to the profiles table. Validate the username format client-side (alphanumeric plus dashes, 3–30 chars) and server-side. Check availability with a debounced Supabase query as the user types. Route public profiles to /profile/[username] instead of /profile/[userId]. Reserve system names like 'admin', 'api', and 'settings' in the validation list.
How do I prevent profanity in display names?
The lightest approach is a client-side word filter using a library like bad-words that checks the value before form submission. For more robust enforcement, run the check in a Supabase Edge Function or Database Trigger before the INSERT/UPDATE is accepted. For community-facing apps, pair automated filtering with a reporting mechanism so users can flag names the filter missed.
What happens to profile data when a user deletes their account?
The profiles table foreign key has ON DELETE CASCADE, so deleting the auth.users row cascades and deletes the profiles row automatically. However, Supabase Storage objects are not automatically deleted by a database cascade — you must also delete the avatar file from the 'avatars' bucket, either in a Database Trigger (using a pg_net HTTP call to the Storage API) or in the account deletion UI handler before calling the auth delete function.
How do I add a verified badge for certain users?
Add a is_verified boolean column (default false) to the profiles table. Only allow this column to be updated by an admin role — add a separate UPDATE policy: using (auth.role() = 'service_role') or using a custom admin_users table check. Never let the user set their own is_verified flag. Display the badge conditionally in the Profile Header component when is_verified is true.
Need this feature production-ready?
RapidDev builds user profiles into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.