User Profile Pages — Chat Prompts
If you're building a SaaS in 2026 with multiple users in shared workspaces — collaborative tools, social products, marketplaces, communities, anywhere users see each other — you'll need user profile pages. The naive shape: an /account/[id] route that shows name + avatar. The right shape: a profile system that handles privacy controls (what's visible to whom), public vs internal profiles, profile ownership boundaries, slug/handle systems, profile editing UX, profile completeness incentives, search/discovery, and the privacy/safety/edge cases that make profiles a real product surface.
Profile pages are deceptively complex because they're at the intersection of identity, privacy, social signaling, and product engagement. Get it right and profiles become the social glue that makes your collaborative product feel alive (and search-engine traffic from public profiles compounds). Get it wrong and you ship a feature nobody uses, plus a few embarrassing privacy incidents.
This chat walks through implementing a real user profile system: data model, public vs private profiles, slug/handle reservation, profile completeness scoring, photo + bio editing, privacy controls, search + discovery, and the operational realities of running profiles at scale.
What you're building
- User profile data model (separate from auth User; richer)
- Public profile pages (SEO-friendly URLs; viewable to internet or workspace)
- Private/workspace-internal profile pages
- Avatar + cover photo + bio editing
- Slug / handle reservation system (unique; case-insensitive)
- Privacy controls (who sees what)
- Profile completeness scoring (drives onboarding nudges)
- Search + discovery (find people)
- Activity / contribution surface (varies per product)
- Edge cases: deleted users, banned users, GDPR data export
1. Decide what KIND of profile you actually need
Help me decide what scope of profile pages to ship.
Three increasingly-rich shapes:
LEVEL 1: WORKSPACE-ONLY PROFILES (the default for most B2B SaaS)
- Profile visible to workspace members only
- Shows: name, avatar, role, bio, contact (email/Slack), recent activity
- No public URL; no SEO surface
- Pros: simple; minimal privacy surface area
- Cons: no public-facing utility (community, marketplace, etc.)
- Time to ship: 2-3 weeks
- Right for: most B2B SaaS where users primarily collaborate within their workspace
LEVEL 2: PUBLIC PROFILES (PLG / community / marketplace)
- Profile has a public URL (e.g. yourco.com/@username)
- Shows: name, avatar, bio, public projects/contributions, follows
- Indexable by search engines
- Privacy controls let user opt out (private profile)
- Pros: distribution + SEO; community building; identity surface
- Cons: privacy + safety + abuse concerns
- Time to ship: 6-10 weeks
- Right for: PLG SaaS, dev-tool communities, marketplaces, social-feature products
LEVEL 3: RICH SOCIAL PROFILES (community-led / social products)
- Public profiles + follows + activity feed + endorsements + projects + custom sections
- Examples: GitHub profile, Linear's user pages, Notion public profiles
- Pros: deepest social/discovery feature
- Cons: ongoing engineering investment; abuse moderation needed
- Time to ship: 12-24 weeks
- Right for: products where identity IS a primary feature
DEFAULT FOR MOST B2B SaaS:
- Year 1-2: Level 1 (workspace-only)
- Year 2+ (when community / PLG / marketplace shape emerges): Level 2
- Year 3+: Level 3 only if user identity is core to product motion
Don't over-build. A workspace-internal profile page satisfies 80% of B2B SaaS needs.
Output: a clear scope decision; explicit "we are NOT building" boundary.
Output: scope statement so PM/eng/design align.
2. Design the data model
Now design the user profile data model.
Schema (Postgres + Drizzle/Prisma):
Note: in most apps, you have an existing 'users' table for authentication. The profile table is SEPARATE because:
- Users own multiple display profiles (e.g., per-workspace)
- Profile data is mutable + frequently updated; auth data should be stable
- Profile data has different privacy rules than auth data
users (auth-related; existing)
- id, email, password_hash, last_login, etc.
user_profiles (new)
- id uuid pk
- user_id uuid not null references users(id)
- workspace_id uuid -- null = global/cross-workspace; uuid = workspace-specific
- slug text -- unique handle; e.g. "alice-doe" or "alice"
- display_name text -- the rendered name
- avatar_url text -- S3 / Blob URL
- cover_url text -- optional
- bio text -- short bio; max 500 chars
- pronouns text -- optional
- timezone text -- IANA tz
- title text -- job title / role
- company text -- if relevant
- location text -- city, country (granular as user sets)
- website_url text
- twitter_handle text
- github_handle text
- linkedin_handle text
- visibility text -- 'public' | 'workspace' | 'private'
- searchable bool -- discoverable in search?
- updated_at timestamptz
- created_at timestamptz
user_profile_links (optional; for many-to-many social links)
- user_profile_id, link_type, link_url, display_order
UNIQUE INDEX (slug) WHERE workspace_id IS NULL -- globally unique handles for public profiles
UNIQUE INDEX (workspace_id, slug) -- workspace-scoped handles
UNIQUE INDEX (workspace_id, user_id) -- one profile per user per workspace
Privacy schema:
profile_privacy_settings
- user_profile_id pk
- show_email_to text -- 'public' | 'workspace' | 'connections' | 'private'
- show_activity_to text
- show_projects_to text
- show_followers_to text
- show_company_to text
- searchable_in_directory bool
Decisions:
1. One global profile vs per-workspace profiles?
- For B2B: per-workspace usually wins (different name in different orgs is allowed)
- For PLG / community: one global profile (cross-workspace identity)
- HYBRID: one global profile + workspace-specific overrides for display fields
2. Email visibility
- Default: workspace-only (NOT public)
- Common abuse: scraping public profiles for emails
- Recommendation: never show raw email publicly; show contact form / "request to message"
3. Slug rules
- 3-30 chars
- Lowercase letters, numbers, hyphens, underscores
- Cannot start with hyphen/number; must start with letter
- Reserved: 'admin', 'api', 'help', 'login', 'logout', 'settings', etc. (your reserved list)
- Case-insensitive uniqueness; preserve original case for display
Indexes:
- (slug) for profile lookup
- (user_id, workspace_id) for "my profile" lookup
- (workspace_id, searchable) for directory queries
- GIN on display_name + bio for search
Implement:
1. Migration SQL
2. Drizzle/Prisma model
3. RLS policies (Supabase)
4. Slug validator (regex + reserved list)
5. Privacy enforcement at query layer
6. TypeScript types
Output: a schema that supports privacy + slugs + flexibility.
3. Build the slug / handle reservation system
Slugs are the most-bespoke part of profile systems. Handle them carefully.
Slug requirements:
- Globally unique (Level 2+) or workspace-unique (Level 1)
- 3-30 characters
- [a-zA-Z0-9_-] regex
- Cannot start with hyphen, number, or reserved character
- Case-preserved on display (Alice vs alice both valid)
- Reserved word list maintained
Reserved words to block:
- System routes: admin, api, login, signup, logout, app, oauth, webhook
- Common words: home, profile, settings, search, help, support, about, contact
- Brand: yourcompany, yourproduct, yourdomain
- Legal: terms, privacy, cookies, gdpr
- Generic-bad: null, undefined, anonymous, deleted, removed
- Single-letter slugs: reserve i, a, e, x, etc. for marketing
- Profanity (use a curated list; not exhaustive but signals respect)
Slug claiming flow:
A. On signup (Level 2+ public profiles):
- User picks slug
- Validate: regex, length, not in reserved list, not in slug-blocklist
- Check uniqueness (case-insensitive)
- If taken: suggest alternatives (alice → alice2, alice-doe, etc.)
B. Reserved-by-user-but-not-yet-set:
- Allow user to "claim" their preferred slug post-signup
- Soft-reserve via session for 60 minutes during onboarding (prevents race)
C. Slug changes:
- Allow user to change once (often)
- After change: redirect old URL to new for 90 days (prevents broken share links)
- Old slug NOT immediately reusable (180-day cooldown) to prevent impersonation
D. Username changes by abuse / deletion:
- Banned users: slug remains reserved (don't release to others)
- Deleted users: slug enters cooldown for 12+ months
Implementation:
async function reserveSlug(userId, requestedSlug) {
const validated = validateSlug(requestedSlug) // regex, length, reserved
if (!validated.ok) throw new InvalidSlugError(validated.reason)
// Check uniqueness (case-insensitive)
const existing = await db.userProfiles.findOne({ slug: requestedSlug.toLowerCase() })
if (existing && existing.user_id !== userId) {
throw new SlugTakenError({ suggestions: generateSuggestions(requestedSlug) })
}
// Atomically reserve
await db.userProfiles.update({ slug: requestedSlug }, { where: { user_id: userId } })
}
function generateSuggestions(slug) {
return [
slug + Math.floor(Math.random() * 1000),
slug + '-1',
slug + '_official',
// ... up to 5 suggestions
].filter(isAvailableSync)
}
Slug-change endpoint:
- Rate limit: 3 changes per year per user (prevents impersonation)
- Save history: profile_slug_history (user_profile_id, old_slug, changed_at)
- Set up redirect: GET /old-slug → 301 → /new-slug for 90 days
Implement:
1. Slug validator
2. Reserved-words list (curated)
3. Suggestions generator
4. Reservation flow
5. Change history + redirect
6. Cooldown logic
Output: slugs that handle real-world abuse + edge cases.
4. Build the public profile page
Public profile page (Level 2+).
Route: /@[slug] (or /u/[slug] / /[slug] / your convention)
Server-render (SSR) for SEO + initial load.
Page sections (typical):
1. Header
- Cover photo (16:5 ratio; optional)
- Avatar (overlapping cover)
- Display name + handle
- Pronouns (if set)
- Bio (1-3 lines)
- Location, timezone (if shared)
- Verification badge (if applicable; e.g. paid customer, contributor)
- Action buttons: "Follow" (Level 3+), "Message", "Edit profile" (if own)
2. Stats (varies per product)
- Number of projects / posts / contributions / etc.
- Followers / following (Level 3+)
- Member-since date
- Badge / achievements (if applicable)
3. Activity / Content (varies per product)
- Recent public projects, posts, contributions
- Pinned items (user-curated)
- Activity feed (Level 3+)
4. Links
- Website, social links, GitHub, etc.
- Validated + visited link icons
5. Workspace memberships (B2B; if showing)
- "Member of [Workspace X], [Workspace Y]"
SEO + meta tags:
- <title>: "Alice Doe (@alice) – Yourapp"
- <meta name="description">: bio (truncated to 155 chars)
- <meta property="og:title">, og:image (avatar or cover), og:type="profile"
- JSON-LD Person schema
- Canonical URL: /@alice (always lowercase canonical)
- Robots: index, follow (if profile public)
- For private profiles: noindex, follow
Performance:
- Server-render initial fetch
- TTFB target: <200ms
- Initial render: <100ms client-side
- Image optimization (Next.js Image / similar; resize avatars; lazy-load cover)
Privacy enforcement:
- Server-side check: visibility setting allows current viewer to see this
- Hide email unless visibility allows
- Hide activity sections per privacy
Edge cases:
- Profile doesn't exist: 404 with helpful copy ("This profile may not exist or has been deleted.")
- Profile is private: "This profile is private" message; no info leaked
- Profile is banned: "This profile is unavailable" (no reason given publicly)
- Slug case mismatch: 301 to canonical case (alice vs Alice)
Build:
1. The /@[slug] page (Server Component)
2. The privacy-aware data loader
3. Meta tags + JSON-LD
4. The header + stats + activity sections
5. Image optimization
6. Edge cases (404, private, banned)
7. The /u/me redirect for "view my profile" (server identifies user, redirects to their slug)
Output: a public profile page that loads fast and indexes well.
5. Build the profile editor
Customers spend significant time on this; make it good.
Settings page (/settings/profile):
Tabs or sections:
1. Public profile (visible per visibility settings)
2. Privacy
3. Avatars + photos
4. Connected accounts
5. Notifications
Public profile tab:
- Avatar upload (square crop preview; 256x256 final; 5MB max)
- Cover photo upload (16:5 crop; 2400x750 final; 10MB max)
- Display name (max 50 chars)
- Slug / handle (with availability check + change-cooldown notice)
- Pronouns (free text or curated list)
- Bio (markdown subset; 500 chars max)
- Location (free text or geocoded)
- Timezone (IANA dropdown)
- Title (e.g. "Engineer at Acme")
- Company
- Website URL
- Social links (Twitter/X, GitHub, LinkedIn, etc.; validated as URLs)
Privacy tab:
- Profile visibility: Public / Workspace / Private
- Show email to: nobody / connections / workspace / public
- Show activity: same options
- Show in search: yes/no
- Allow message: yes/no (if applicable)
Avatar editing:
- Upload (drag-drop or button)
- Auto-crop to square; user can adjust
- Generate fallback (initials in colored circle) if not uploaded
- Real-time preview
- Optimize via [Cloudinary / your image pipeline] on save
Bio editing:
- Markdown subset (bold, italic, links, lists; NOT images, NOT raw HTML)
- Sanitize on save (DOMPurify or your equivalent)
- Char count visible
- Preview tab
Save UX:
- Auto-save (debounced 500ms after last keystroke)
- Or: explicit save button per section
- Optimistic UI updates
- Server validation; error messaging
Live preview panel:
- Show "Here's what your profile looks like to others" alongside editor
- Toggle: as logged-out viewer / as workspace member / as connection
Implement:
1. The /settings/profile page (with tabs)
2. The avatar upload + crop component
3. The cover photo upload
4. The slug change UX (with cooldown notice)
5. The bio editor (markdown subset; sanitized)
6. The privacy panel
7. The live preview
8. The auto-save flow
Output: a settings page customers don't need to abandon halfway.
6. Build the privacy + visibility enforcement
Privacy is the most important part. Enforce server-side; never trust client.
Privacy levels:
PROFILE-WIDE:
- public: anyone (even logged-out) can view
- workspace: only workspace members can view
- private: only owner + admins can view
FIELD-LEVEL:
- email: who can see email
- activity: who can see activity feed
- followers/following: who can see social graph
- workspace memberships: who can see other workspaces
Enforcement layer:
async function getProfileForViewer(slug, viewerId) {
const profile = await db.userProfiles.findBySlug(slug)
if (!profile) return null
const viewerIsOwner = profile.user_id === viewerId
const viewerIsAdmin = await isAdmin(viewerId, profile.workspace_id)
const viewerIsWorkspaceMember = await isMember(viewerId, profile.workspace_id)
// Profile-wide visibility check
if (profile.visibility === 'private' && !viewerIsOwner && !viewerIsAdmin) {
return null // 404 or "private"
}
if (profile.visibility === 'workspace' && !viewerIsWorkspaceMember && !viewerIsOwner && !viewerIsAdmin) {
return null
}
// Field-level masking
const masked = { ...profile }
if (!canSeeField(profile.privacy.show_email_to, { viewerIsOwner, viewerIsWorkspaceMember, ... })) {
delete masked.email
}
// ... repeat for each privacy-controlled field
return masked
}
Anti-patterns:
- Don't filter on the client (client-side data leaks via DevTools)
- Don't return all fields and let UI hide (server does the masking)
- Don't bypass privacy on internal admin tools (still apply rules; admin override should be explicit + audit-logged)
Test cases:
- Logged-out viewer → public profile loads correctly
- Logged-out viewer → workspace profile returns "not found"
- Logged-in non-member → workspace profile returns "not found"
- Workspace member → workspace profile loads with appropriate field-level masking
- Admin → can see private profile with override notice
- Owner → sees own profile fully
Implement:
1. The privacy enforcement function
2. The masking logic per field
3. Admin override audit logging
4. Comprehensive test coverage (this is high-stakes)
5. RLS policies enforcing same logic at DB level (defense in depth)
Output: privacy that doesn't leak.
7. Build search + directory
For Level 2+ public-profile products: discovery matters.
Search use cases:
- @-mentions in product (autocomplete by name/handle)
- Workspace member directory (see who's in your workspace)
- Public profile search (find users by name / interest / location)
Schema additions:
CREATE INDEX user_profiles_search_idx ON user_profiles
USING GIN (to_tsvector('english', display_name || ' ' || coalesce(bio, '') || ' ' || coalesce(title, '')))
WHERE searchable = true;
Or use [Algolia / Typesense / Elasticsearch / Postgres FTS].
Search modes:
A. @-mention autocomplete (in-app):
- Triggered by '@' in text fields
- Search workspace members first; then global if Level 2+
- Filter by recent collaborators
- Indexed by display_name + slug
- Performance: <100ms p95
B. Member directory (in-workspace):
- /workspace/[id]/members
- Filter by role, team, location
- Sort by name, join-date, last-active
- Pagination
C. Public profile search (Level 2+):
- /search?q=alice
- Public profiles only
- Filter by location, interests (if structured)
- Don't expose: email, internal data
D. Connections / "people you may know":
- Suggested based on shared workspaces, mutual connections
- Privacy-respecting
Privacy in search:
- searchable = false → exclude from results
- Private profiles → never indexable
- Email is NEVER searchable (don't index email field)
Anti-abuse:
- Rate limit search (per IP, per user)
- Detect scraping patterns
- Don't return more than N results per query (e.g., 50)
- CAPTCHA on excessive queries
Implement:
1. The search index (Postgres FTS or external)
2. The @-mention autocomplete component
3. The directory page
4. The search results page
5. The privacy + searchability enforcement
6. The anti-abuse rate limiting
Output: discovery that respects privacy.
8. Profile completeness scoring + onboarding nudges
Profiles drive engagement when complete. Nudge users to complete.
Completeness score:
function computeCompleteness(profile) {
let score = 0
if (profile.avatar_url) score += 25
if (profile.display_name) score += 10
if (profile.bio?.length > 30) score += 15
if (profile.title) score += 10
if (profile.location) score += 10
if (profile.timezone) score += 5
if (profile.twitter_handle || profile.github_handle || profile.linkedin_handle) score += 10
if (profile.website_url) score += 5
if (profile.cover_url) score += 10
return Math.min(100, score)
}
Use:
- Show completeness bar in settings ("Your profile is 70% complete")
- Highlight missing fields
- Onboarding day-7 email: "complete your profile in 2 minutes"
- In-app prompts at relevant moments ("Add your title to look more legit on shared boards")
Don't:
- Block features behind completeness (often illegal/anti-pattern)
- Spam users about profile every day
- Pretend incomplete profiles are broken
Implement:
1. The completeness scorer
2. The progress bar UI
3. The "missing fields" suggestion list
4. The onboarding nudges (email + in-app)
5. Analytics tracking (which fields drive completion?)
Output: gentle nudges that drive completion without being annoying.
9. Operational concerns + edge cases
Walk me through:
1. User deletes account
- GDPR: anonymize profile (replace name/avatar/bio with "Deleted user"; keep activity but de-identified)
- Slug: enter cooldown for 12+ months
- Public profile URL: 410 Gone (not 404) with appropriate copy
2. User is banned for abuse
- Profile becomes private OR returns "unavailable"
- Slug stays reserved
- Don't reveal reason publicly
3. User changes name (legal name change, marriage, etc.)
- Display name change unlimited
- Slug change limited (cooldown to prevent impersonation)
- Old display name in activity history (audit) preserved
4. Avatar / cover photo abuse (NSFW, copyrighted, hate symbols)
- Moderation queue; report button
- Auto-flag via [AWS Rekognition / OpenAI Moderation / similar]
- Manual review for borderline; auto-revert on confirmed violation
- Replace with default + warning to user
5. Slug squatting / impersonation
- Reserved for known brands (e.g., "stripe", "google", "openai")
- Verification badge for paid customers / verified entities
- Trademark complaint process
6. Profile age / fake accounts
- Age-gate for under-13 (COPPA)
- Email verification required for public profiles
- Optional: phone verification for higher-trust tiers
7. Bot / spam profiles
- Detect: rapid signups, identical bio/avatar patterns
- Auto-suspend; manual review
- CAPTCHA on signup
8. SEO concerns
- Public profiles indexed (drives long-tail traffic)
- Sitemaps include public profiles (filtered to active + completable)
- Don't index empty profiles (low-value SEO)
- rel="me" links to social to verify identity
9. Mobile profile rendering
- Responsive design from day one
- Edit flow on mobile (tricky; test extensively)
10. International characters / RTL languages
- Display name supports any Unicode
- RTL: detect Arabic / Hebrew; layout adjusts
- Slug remains ASCII-only (avoid ambiguity)
11. Privacy regulation compliance
- GDPR right-to-export: include full profile in export
- GDPR right-to-erasure: full deletion (with audit)
- CCPA: California-specific opt-outs
12. Backwards compatibility on schema changes
- Profile schema evolves over time
- Don't break old slugs / old URLs
- 301 redirects for slug changes
For each, walk me through code change + UX impact.
Output: a profile system that survives real users.
10. Recap
What you've built:
- Profile data model (separate from auth User)
- Slug / handle reservation with reserved-words + cooldown
- Public profile page (Level 2+) with SEO
- Workspace-only profile page (Level 1)
- Profile editor with avatar / cover / bio
- Privacy + visibility enforcement (server-side)
- Search + directory + @-mention autocomplete
- Completeness scoring + onboarding nudges
- Operational handling for deletion / abuse / impersonation
What you're explicitly NOT building in v1:
- Follow / unfollow social graph (Level 3; defer until needed)
- Activity feed across users (defer; needs eventing backbone)
- Endorsements / skills / certifications (defer)
- Custom profile sections / blocks (defer)
- Multiple profiles per user (rare; defer)
- Profile themes / customization (defer)
Ship Level 1 in 2-3 weeks; Level 2 in 6-10 weeks. Add Level 3 only if community is core to product motion.
The biggest mistake teams make: building public profiles before the product needs them. If users primarily collaborate within their workspace, public profiles are noise + privacy surface area without payoff.
The second mistake: client-side privacy enforcement. Every privacy decision must be server-side. The DevTools attack is real.
The third mistake: skipping the slug reserved-words list. "/admin" or "/api" as slugs route to your routes; chaos ensues.
See Also
- Settings & Account Pages — adjacent settings UX
- Avatar Upload & Image Cropping — image handling
- File Uploads — broader file handling
- Image Upload Processing Pipeline — image pipeline
- Roles & Permissions — pairs for visibility logic
- Multi-Tenancy — workspace boundary
- SSO / Enterprise Auth — adjacent identity
- Two-Factor Auth — adjacent identity
- Account Deletion & Data Export — GDPR for profiles
- Audit Logs — pairs for profile-change audit
- Slugs & URL Handling — adjacent URL discipline
- Search & Autocomplete / Typeahead — adjacent search component
- Comments, Threading, Mentions — uses @-mention autocomplete
- SEO Setup — pairs for public-profile SEO
- Programmatic SEO — public profiles can be a programmatic SEO surface
- Workspace Branding & Custom Domains — pairs for white-label profiles
- Public Share Links & Permissioned Sharing — adjacent public-surface privacy