Skip to main content
Learn how to build a user management system that allows users to sign up, sign in, and manage their profile information including avatar images.

What You’ll Build

A user management system with:
  • Email/password authentication
  • User profile management
  • Avatar image uploads
  • Public profile pages
  • Row Level Security for data protection

Architecture Overview

This application uses:
  • Supabase Auth for user authentication
  • Supabase Database for storing profile data
  • Supabase Storage for avatar images
  • Next.js App Router for the frontend
1

Create a New Project

  1. Go to supabase.com/dashboard
  2. Click New Project
  3. Configure your project:
    • Name: User Management App
    • Database Password: Use a strong password
    • Region: Choose your preferred region
  4. Click Create new project
2

Run the User Management Quickstart

In the SQL Editor:
  1. Navigate to SQL Editor
  2. Find User Management Starter: Sets up a public Profiles table
  3. Click Run
This creates the necessary database schema:
-- Create a table for public profiles
create table profiles (
  id uuid references auth.users on delete cascade not null primary key,
  updated_at timestamp with time zone,
  username text unique,
  full_name text,
  avatar_url text,
  website text,
  constraint username_length check (char_length(username) >= 3)
);

-- Set up Row Level Security (RLS)
alter table profiles enable row level security;

create policy "Public profiles are viewable by everyone." 
  on profiles for select 
  using (true);

create policy "Users can insert their own profile." 
  on profiles for insert 
  with check ((select auth.uid()) = id);

create policy "Users can update own profile." 
  on profiles for update 
  using ((select auth.uid()) = id);

-- Auto-create profile on user signup
create function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, full_name, avatar_url)
  values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
  return new;
end;
$$ language plpgsql security definer;

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

-- Set up Storage
insert into storage.buckets (id, name)
  values ('avatars', 'avatars');

-- Storage policies
create policy "Avatar images are publicly accessible." 
  on storage.objects for select 
  using (bucket_id = 'avatars');

create policy "Anyone can upload an avatar." 
  on storage.objects for insert 
  with check (bucket_id = 'avatars');

create policy "Anyone can update their own avatar." 
  on storage.objects for update 
  using (auth.uid()::text = (storage.foldername(name))[1]);
3

Set Up Your Next.js App

Create a new Next.js application:
npx create-next-app@latest user-management
cd user-management
Install Supabase packages:
npm install @supabase/supabase-js @supabase/ssr
Create .env.local:
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
4

Create Supabase Client Utilities

Create lib/supabase/client.ts for client-side operations:
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}
Create lib/supabase/server.ts for server-side operations:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Server Component - ignore
          }
        },
      },
    }
  )
}
5

Build the Avatar Component

Create app/account/avatar.tsx:
'use client'
import React, { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import Image from 'next/image'

export default function Avatar({
  uid,
  url,
  size,
  onUpload,
}: {
  uid: string | null
  url: string | null
  size: number
  onUpload: (url: string) => void
}) {
  const supabase = createClient()
  const [avatarUrl, setAvatarUrl] = useState<string | null>(url)
  const [uploading, setUploading] = useState(false)

  useEffect(() => {
    async function downloadImage(path: string) {
      try {
        const { data, error } = await supabase.storage
          .from('avatars')
          .download(path)
        
        if (error) throw error
        
        const url = URL.createObjectURL(data)
        setAvatarUrl(url)
      } catch (error) {
        console.log('Error downloading image: ', error)
      }
    }

    if (url) downloadImage(url)
  }, [url, supabase])

  const uploadAvatar: React.ChangeEventHandler<HTMLInputElement> = async (event) => {
    try {
      setUploading(true)

      if (!event.target.files || event.target.files.length === 0) {
        throw new Error('You must select an image to upload.')
      }

      const file = event.target.files[0]
      const fileExt = file.name.split('.').pop()
      const filePath = `${uid}-${Math.random()}.${fileExt}`

      const { error: uploadError } = await supabase.storage
        .from('avatars')
        .upload(filePath, file)

      if (uploadError) throw uploadError

      onUpload(filePath)
    } catch (error) {
      alert('Error uploading avatar!')
    } finally {
      setUploading(false)
    }
  }

  return (
    <div className="flex flex-col items-center gap-4">
      {avatarUrl ? (
        <Image
          width={size}
          height={size}
          src={avatarUrl}
          alt="Avatar"
          className="rounded-full"
        />
      ) : (
        <div 
          className="rounded-full bg-gray-200" 
          style={{ height: size, width: size }} 
        />
      )}
      <div>
        <label 
          className="px-4 py-2 bg-blue-500 text-white rounded cursor-pointer" 
          htmlFor="single"
        >
          {uploading ? 'Uploading ...' : 'Upload Avatar'}
        </label>
        <input
          className="hidden"
          type="file"
          id="single"
          accept="image/*"
          onChange={uploadAvatar}
          disabled={uploading}
        />
      </div>
    </div>
  )
}
6

Build the Account Form

Create app/account/account-form.tsx:
'use client'
import { useCallback, useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { type User } from '@supabase/supabase-js'
import Avatar from './avatar'

export default function AccountForm({ user }: { user: User | null }) {
  const supabase = createClient()
  const [loading, setLoading] = useState(true)
  const [fullname, setFullname] = useState<string | null>(null)
  const [username, setUsername] = useState<string | null>(null)
  const [website, setWebsite] = useState<string | null>(null)
  const [avatar_url, setAvatarUrl] = useState<string | null>(null)

  const getProfile = useCallback(async () => {
    try {
      setLoading(true)

      const { data, error, status } = await supabase
        .from('profiles')
        .select(`full_name, username, website, avatar_url`)
        .eq('id', user?.id)
        .single()

      if (error && status !== 406) throw error

      if (data) {
        setFullname(data.full_name)
        setUsername(data.username)
        setWebsite(data.website)
        setAvatarUrl(data.avatar_url)
      }
    } catch (error) {
      alert('Error loading user data!')
    } finally {
      setLoading(false)
    }
  }, [user, supabase])

  useEffect(() => {
    getProfile()
  }, [user, getProfile])

  async function updateProfile({
    username,
    website,
    avatar_url,
  }: {
    username: string | null
    fullname: string | null
    website: string | null
    avatar_url: string | null
  }) {
    try {
      setLoading(true)

      const { error } = await supabase.from('profiles').upsert({
        id: user?.id as string,
        full_name: fullname,
        username,
        website,
        avatar_url,
        updated_at: new Date().toISOString(),
      })
      
      if (error) throw error
      alert('Profile updated!')
    } catch (error) {
      alert('Error updating the data!')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="max-w-md mx-auto p-8 space-y-6">
      <Avatar
        uid={user?.id ?? null}
        url={avatar_url}
        size={150}
        onUpload={(url) => {
          setAvatarUrl(url)
          updateProfile({ fullname, username, website, avatar_url: url })
        }}
      />
      
      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input 
          id="email" 
          type="text" 
          value={user?.email} 
          disabled 
          className="mt-1 block w-full rounded border p-2 bg-gray-100"
        />
      </div>
      
      <div>
        <label htmlFor="fullName" className="block text-sm font-medium">
          Full Name
        </label>
        <input
          id="fullName"
          type="text"
          value={fullname || ''}
          onChange={(e) => setFullname(e.target.value)}
          className="mt-1 block w-full rounded border p-2"
        />
      </div>
      
      <div>
        <label htmlFor="username" className="block text-sm font-medium">
          Username
        </label>
        <input
          id="username"
          type="text"
          value={username || ''}
          onChange={(e) => setUsername(e.target.value)}
          className="mt-1 block w-full rounded border p-2"
        />
      </div>
      
      <div>
        <label htmlFor="website" className="block text-sm font-medium">
          Website
        </label>
        <input
          id="website"
          type="url"
          value={website || ''}
          onChange={(e) => setWebsite(e.target.value)}
          className="mt-1 block w-full rounded border p-2"
        />
      </div>

      <div className="space-y-2">
        <button
          className="w-full px-4 py-2 bg-blue-500 text-white rounded"
          onClick={() => updateProfile({ fullname, username, website, avatar_url })}
          disabled={loading}
        >
          {loading ? 'Loading ...' : 'Update Profile'}
        </button>

        <form action="/auth/signout" method="post">
          <button className="w-full px-4 py-2 bg-gray-200 rounded" type="submit">
            Sign Out
          </button>
        </form>
      </div>
    </div>
  )
}
7

Create the Account Page

Create app/account/page.tsx:
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import AccountForm from './account-form'

export default async function Account() {
  const supabase = await createClient()

  const {
    data: { user },
  } = await supabase.auth.getUser()

  if (!user) {
    return redirect('/login')
  }

  return <AccountForm user={user} />
}
8

Create the Login Page

Create app/login/page.tsx:
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { login, signup } from './actions'

export default async function LoginPage() {
  const supabase = await createClient()
  const { data } = await supabase.auth.getUser()

  if (data?.user) {
    redirect('/account')
  }

  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="max-w-md w-full p-8 bg-white rounded-lg shadow">
        <h1 className="text-2xl font-bold mb-6">Sign In</h1>
        <form className="space-y-4">
          <div>
            <label htmlFor="email" className="block text-sm font-medium">
              Email
            </label>
            <input
              id="email"
              name="email"
              type="email"
              required
              className="mt-1 block w-full rounded border p-2"
            />
          </div>
          
          <div>
            <label htmlFor="password" className="block text-sm font-medium">
              Password
            </label>
            <input
              id="password"
              name="password"
              type="password"
              required
              className="mt-1 block w-full rounded border p-2"
            />
          </div>
          
          <div className="flex gap-2">
            <button 
              formAction={login}
              className="flex-1 px-4 py-2 bg-blue-500 text-white rounded"
            >
              Sign In
            </button>
            <button 
              formAction={signup}
              className="flex-1 px-4 py-2 bg-gray-200 rounded"
            >
              Sign Up
            </button>
          </div>
        </form>
      </div>
    </div>
  )
}
Create app/login/actions.ts:
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function login(formData: FormData) {
  const supabase = await createClient()

  const data = {
    email: formData.get('email') as string,
    password: formData.get('password') as string,
  }

  const { error } = await supabase.auth.signInWithPassword(data)

  if (error) {
    redirect('/error')
  }

  revalidatePath('/', 'layout')
  redirect('/account')
}

export async function signup(formData: FormData) {
  const supabase = await createClient()

  const data = {
    email: formData.get('email') as string,
    password: formData.get('password') as string,
  }

  const { error } = await supabase.auth.signUp(data)

  if (error) {
    redirect('/error')
  }

  revalidatePath('/', 'layout')
  redirect('/account')
}
9

Test Your Application

Run the development server:
npm run dev
Visit http://localhost:3000/login:
  1. Create a new account
  2. Fill in your profile information
  3. Upload an avatar image
  4. Update your profile

Key Concepts

Row Level Security

RLS policies ensure:
  • Anyone can view profiles (select using (true))
  • Users can only update their own profile
  • Profiles are automatically created on signup via trigger

Storage Policies

Storage policies control:
  • Public read access to avatars
  • Authenticated users can upload
  • Users can only update their own avatars

Server vs Client Components

Next.js App Router pattern:
  • Server Components: Fetch user data securely
  • Client Components: Handle interactivity (forms, uploads)

Next Steps

Add Social Auth

Enable Google, GitHub, and other OAuth providers

File Upload Tutorial

Learn advanced storage techniques

Realtime Presence

Show online users in real-time

Example Code

Explore more authentication examples