Auth & Users · Starter
User Profile
Profile editing form with avatar upload to Supabase Storage, Zod validation, notification preferences, and soft-delete account removal. Designed to sit on top of the Auth block's profiles table.
Read the Getting Access guideif you haven't yet.
1. Get the file
Sign in at marrowstack.dev, open the User Profile block, and click Copy all files. Paste lib/profile.ts into your project. The SQL migration is embedded in the MIGRATION constant.
2. Prerequisites
- Next.js 14 or 15, App Router
- Supabase project with the Auth block running (reads the
profilestable) - A Supabase Storage bucket named
avatars(public or private)
3. Install
- Copy
lib/profile.tsfrom the block detail page and paste it into your project. - Install peer dependencies:
npm install @supabase/supabase-js zod4. Environment
| Variable | Required | Default | Purpose |
|---|---|---|---|
| NEXT_PUBLIC_SUPABASE_URL | yes | — | Your Supabase project URL |
| NEXT_PUBLIC_SUPABASE_ANON_KEY | yes | — | Anon key — used client-side for avatar uploads |
| SUPABASE_SERVICE_ROLE_KEY | yes | — | Service role key — used server-side to update profiles |
| AVATAR_BUCKET | no | — | Storage bucket name. Defaults to avatars |
5. Database
Run the MIGRATION constant in Supabase SQL Editor. Adds avatar_url, bio, notification_preferences, and deleted_at columns to the existing profiles table using ADD COLUMN IF NOT EXISTS. The Auth block's migration must be run first.
Also create the storage bucket in Supabase Dashboard → Storage → New bucket → name it avatars. Set visibility to Public for direct URL access, or Private if you want signed URLs.
6. Wire it in
import { updateProfile, uploadAvatar } from '@/lib/profile'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { NextRequest, NextResponse } from 'next/server'
export async function PATCH(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) return new NextResponse('Unauthorized', { status: 401 })
const body = await req.json()
const updated = await updateProfile(session.user.id, body)
return NextResponse.json(updated)
}
// Avatar upload — accepts multipart/form-data
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) return new NextResponse('Unauthorized', { status: 401 })
const form = await req.formData()
const file = form.get('avatar') as File
const { avatarUrl } = await uploadAvatar(session.user.id, file)
return NextResponse.json({ avatarUrl })
}7. Verify it works
- Call
updateProfile(userId, { bio: 'hello' })and confirm the row updates in theprofilestable. - Upload a test image via
uploadAvatar()and confirm the file appears in Supabase Storage. - Call
softDeleteAccount(userId)and confirmdeleted_atis set on the profile row.
8. Failure modes & fixes
column 'bio' does not exist
Cause: The profile migration hasn't been run, or ran before the Auth migration created the profiles table.
Fix: Run the Auth block migration first, then the Profile migration.
Bucket not found
Cause: The avatars storage bucket doesn't exist.
Fix: Create it in Supabase Dashboard → Storage → New bucket.
new row violates row-level security policy
Cause: Using the anon key to write profile data directly.
Fix: All writes should go through the service role key on the server. Never call updateProfile() client-side.