# User Profile
Profile editing form for Next.js 14 — avatar upload to Supabase Storage, field validation with Zod, notification preferences, and soft-delete account removal, all in a single client component.
## What's included
**Server functions**
- `uploadAvatar(userId, file)` — uploads to the `avatars` Supabase Storage bucket at `{userId}/avatar.{ext}`, upserts on re-upload, updates `avatar_url` in `profiles`, returns the public URL
- `updateProfile(userId, data)` — patches the extended profile columns; strips `@` prefix from Twitter handle; clears optional fields with `null` if empty
- `deleteAccount(userId)` — soft delete: anonymizes email, name, bio, avatar, and password hash in-place; does not hard-delete the row
**UI**
- `ProfileForm` — client component with avatar preview + file picker, five text/textarea fields, two notification checkboxes, and a save button; accepts `userId`, `defaultValues`, `currentAvatar`, `onSuccess`
**Schema**
- `ProfileSchema` (internal) — Zod schema covering `name`, `bio`, `website`, `twitter`, `location`, `notifications_email`, `notifications_marketing`
- `ProfileInput` — inferred TypeScript type
## Setup
### 1. Install dependencies
```bash
npm install react-hook-form @hookform/resolvers zod @supabase/supabase-js
```
### 2. Environment variables
```
NEXT_PUBLIC_SUPABASE_URL=your Supabase project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=anon public key
```
### 3. Database
```sql
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS bio TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS website TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS twitter TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS location TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS notifications_email BOOLEAN DEFAULT true;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS notifications_marketing BOOLEAN DEFAULT false;
```
### 4. Storage bucket
In the Supabase dashboard: **Storage → New Bucket → name it `avatars` → set to Public**.
If you want private avatars served via signed URLs, set the bucket to private and replace `getPublicUrl` in `uploadAvatar` with `createSignedUrl`.
## Usage examples
```tsx
// app/profile/page.tsx — server component fetches data, passes to form
import { getServerSession } from 'next-auth'
import { authOptions } from '@/blocks/auth'
import { getProfile } from '@/blocks/auth'
import { ProfileForm } from '@/blocks/profile'
export default async function ProfilePage() {
const session = await getServerSession(authOptions)
const profile = await getProfile(session.user.id)
return (
<ProfileForm
userId={session.user.id}
currentAvatar={profile?.avatar_url}
defaultValues={{
name: profile?.name || '',
bio: profile?.bio || '',
website: profile?.website || '',
twitter: profile?.twitter || '',
location: profile?.location || '',
notifications_email: profile?.notifications_email ?? true,
notifications_marketing: profile?.notifications_marketing ?? false,
}}
onSuccess={() => {
// e.g. router.refresh() to re-fetch server component data
}}
/>
)
}
```
```ts
// Calling server functions directly (e.g. from an API route)
import { updateProfile, uploadAvatar } from '@/blocks/profile'
// Update fields only
await updateProfile(userId, { name: 'Jane', notifications_marketing: true })
// Upload from a FormData request
const file = formData.get('avatar') as File
const url = await uploadAvatar(userId, file)
```
## Notes
- The file has `'use client'` at the top — `uploadAvatar`, `updateProfile`, and `deleteAccount` use the anon Supabase client, so they run in the browser with the user's session; make sure your `profiles` RLS policies allow users to update their own row (`auth.uid()::text = id::text`), otherwise updates will silently fail
- `deleteAccount` is a soft delete only — it anonymizes the row but does not revoke active sessions or delete the Supabase Auth user; call `supabase.auth.admin.deleteUser(userId)` via the service role client if you need a hard delete
- Avatar uploads overwrite the same path (`{userId}/avatar.{ext}`) on every upload; Supabase CDN may cache the old image for up to the `cacheControl` duration (3600s) — append a cache-busting query param to the returned URL if you need the new image to show immediately
- Error handling in `ProfileForm` uses `alert()` — replace with a toast or inline error state before shipping