# How to Add User Profiles to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

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.

## 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** (ui): File 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.
- **Profile Form** (ui): react-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.
- **Profile Header** (ui): Renders 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.
- **Privacy Controls** (ui): Toggle 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.
- **Profile API Layer** (backend): Supabase 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.
- **Profiles Table** (data): Supabase 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** (service): Supabase 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.

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

```sql
create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  display_name text not null,
  bio text,
  avatar_url text,
  website text,
  is_email_public boolean not null default false,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

alter table public.profiles enable row level security;

-- Any authenticated user can read public fields
create policy "Public profiles are viewable by authenticated users"
  on public.profiles for select
  to authenticated
  using (true);

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

-- Auto-create a profile row when a new auth user signs up
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, display_name)
  values (new.id, coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)));
  return new;
end;
$$;

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

-- Index for fast profile lookups by id (primary key covers this, but explicit for clarity)
create index profiles_updated_at_idx on public.profiles (updated_at desc);
```

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 paths

### Lovable — fit 5/10, 2–3 hours

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.

1. Create a new Lovable project with Lovable Cloud enabled so Supabase is provisioned automatically
2. Paste 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
3. After generation, open the Cloud tab and navigate to Storage to verify the 'avatars' bucket was created with the correct RLS INSERT policy
4. Preview the profile page in the Lovable mobile preview by scanning the QR code — avatar upload requires HTTPS to work
5. Click Publish and test the public profile URL by copying the shareable link and opening it in an incognito window

Starter prompt:

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

Limitations:

- 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

### V0 — fit 4/10, 3–4 hours

V0 generates polished profile UI with shadcn/ui components and Next.js Server Actions. Best when user profiles are part of a larger Next.js application with an existing Supabase project.

1. Paste the prompt below in V0 to generate the profile edit and public profile components
2. Add your Supabase environment variables in the Vars panel: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY
3. Run the SQL schema from this page in your Supabase SQL Editor to create the profiles table, RLS policies, and auth trigger
4. In Supabase Storage, create a bucket named 'avatars' and add an INSERT policy: allow insert for authenticated where (storage.foldername(name))[1] = auth.uid()::text
5. Publish to Vercel and test the avatar upload on the live HTTPS URL — localhost may not trigger the crop component identically

Starter prompt:

```
Create a user profile feature in Next.js with Supabase. Page 1: /profile/edit — a profile edit form using react-hook-form and zod with these fields: display_name (Input, required, max 50 chars), bio (Textarea with rows={4}, max 200 chars), website (Input, must be a valid URL or empty). Add an avatar uploader using react-dropzone that accepts image files under 5MB (show inline error if exceeded), previews the selected image with a crop overlay using react-image-crop constrained to a 1:1 aspect ratio with a circle preview, and uploads the cropped file to Supabase Storage bucket 'avatars' at path user/{user_id}/avatar.jpg after save. On form submit, UPSERT to the Supabase profiles table using a Server Action. Show a loading skeleton (shadcn/ui Skeleton) while profile data loads. Show a success Toast on save and an error state with retry if the API call fails. Include a privacy toggle (shadcn/ui Switch) for is_email_public. Page 2: /profile/[userId] — public read-only profile view. Fetch public columns only (display_name, bio, avatar_url, website). Never show email here. Add a copy-to-clipboard button for the profile URL. Mark this page with 'use client' only where interactivity is required — keep data fetching in a Server Component.
```

Limitations:

- V0 does not auto-provision Supabase RLS — you must paste and run the SQL from this page manually in the Supabase SQL Editor
- localStorage SSR errors can appear if profile state is read outside of a useEffect or Server Component — mark client-only components with 'use client'
- Vercel Blob is sometimes suggested by V0 as an alternative to Supabase Storage — stick with Supabase Storage if your profiles table is already in Supabase to keep RLS consistent

### Flutterflow — fit 4/10, 3–5 hours

FlutterFlow's Supabase backend queries map directly to profile read and write operations. Built-in user state management pairs naturally with the profiles table. Right choice for mobile-first apps needing both iOS and Android.

1. In FlutterFlow, connect your Supabase project and add the profiles table via Supabase Settings in the FlutterFlow backend panel
2. Create the Profile Edit page: add TextFormField widgets for display_name, bio, and website; add an image picker using the image_picker action and store the result in a page state variable
3. Add a Supabase Upload File action connected to the avatar image, then a Supabase Update Row action for the profiles table — chain them in the Action editor so avatar uploads before the profile updates
4. Add a Switch widget for is_email_public connected to a page state boolean; include it in the UPDATE action payload
5. Create the Public Profile page with a Supabase Query filtered by user_id, showing only display_name, bio, avatar_url, and website — add conditional visibility to hide any row where the field is null

Limitations:

- Avatar cropping to a circle requires a custom Dart code widget — the built-in image picker does not crop; plan for one custom action to handle crop before upload
- Privacy toggles using conditional visibility logic in the FlutterFlow widget tree can get complex; test the is_email_public condition on the public view carefully
- The auto-create profile trigger must be set up in Supabase SQL Editor directly, not through FlutterFlow's visual schema builder

### Custom — fit 2/10, 3–5 days

Custom development is only warranted when profile complexity exceeds what Supabase + AI tools handle well. For standard edit/view/avatar workflows, it adds cost without value.

1. Implement reputation scoring, computed fields, or verification badge systems that require background jobs and third-party identity APIs (Persona, Stripe Identity)
2. Build a social graph layer — follower/following counts, mutual connections, and block lists that compound table relationships beyond simple CRUD
3. Add bidirectional CRM sync (Salesforce, HubSpot) where profile updates in the app must propagate to the CRM and vice versa via webhook listeners
4. Support multi-tenant profile schemas where each organization defines its own custom fields with different visibility rules per org

Limitations:

- For standard profile CRUD with avatar and privacy controls, custom dev takes 3–5x longer than the AI-tool paths with no user-facing difference in the result

## Gotchas

- **Updated profile data doesn't reflect immediately after save** — 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** — 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** — 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** — 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** — 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

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

---

Source: https://www.rapidevelopers.com/app-features/user-profiles
© RapidDev — https://www.rapidevelopers.com/app-features/user-profiles
