Overview
The /api/admin/users endpoint provides comprehensive user management for administrators. It supports listing, filtering, role changes, password resets, and user deletion with built-in safety guardrails.
All methods require admin authentication . Users with app_metadata.user_type !== 'admin' receive 403 Forbidden.
Authentication
All requests require a Supabase access token from an admin user:
Authorization : Bearer <admin_access_token>
The endpoint validates:
Token is valid and not expired
User has app_metadata.user_type === 'admin'
All responses include:
Never cache admin API responses.
GET /api/admin/users
List and search users with filtering, pagination, and platform statistics.
Query Parameters
Search term matched against email and display name (case-insensitive).
Filter by user role. Options:
all — All users
admin — Admin users only
member — Non-admin users only
Filter by email confirmation status. Options:
all — All users
confirmed — Only users with confirmed emails
unconfirmed — Only users with unconfirmed emails
Page number (1-indexed). Min: 1, Max: 10000.
Items per page. Min: 1, Max: 100.
Response
Array of user objects for the current page. Resolved from user_metadata.full_name, user_metadata.name, user_metadata.display_name, or email. Defaults to "Unknown".
Normalized from app_metadata.user_type
ISO timestamp of account creation
ISO timestamp of most recent sign-in
ISO timestamp of email confirmation (null if unconfirmed)
Authentication providers used (e.g., ["email", "google"])
Pagination metadata. Total matching users (after filters)
Platform-wide statistics (cached for 60 seconds). Users who signed in within the last 7 days
Users with confirmed email addresses
Total saved ads across all users
Total ads in the feed index
Example Request
curl "https://your-domain.vercel.app/api/admin/users?search=john&role=member&page=1&perPage=25" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
Example Response
{
"users" : [
{
"id" : "a1b2c3d4-e5f6-7890-abcd-ef1234567890" ,
"email" : "[email protected] " ,
"displayName" : "John Doe" ,
"userType" : "member" ,
"createdAt" : "2026-01-15T10:30:00.000Z" ,
"lastSignInAt" : "2026-03-02T08:45:00.000Z" ,
"emailConfirmedAt" : "2026-01-15T10:35:00.000Z" ,
"providers" : [ "email" , "google" ]
}
],
"pagination" : {
"page" : 1 ,
"perPage" : 25 ,
"total" : 1 ,
"totalPages" : 1
},
"stats" : {
"totalUsers" : 127 ,
"adminUsers" : 3 ,
"recentlyActiveUsers7d" : 42 ,
"confirmedUsers" : 115 ,
"savedAds" : 1548 ,
"projects" : 89 ,
"indexedAds" : 12456
}
}
Sorting Behavior
Users are sorted by (source:api/admin/users.js:186-194):
Role — Admins first, then members
Creation date — Newest first within each role group
Caching
The user list is cached in-memory for 15 seconds to avoid repeated full scans (source:api/admin/users.js:17).
Statistics are cached separately for 60 seconds (source:api/admin/users.js:21).
PATCH /api/admin/users
Update a user’s role (admin/member).
Request Body
UUID of the user to update
New role for the user. Options:
Safety Guardrails
The following operations are blocked to prevent lockout:
Self-demotion : Admin cannot remove their own admin access
Last admin protection : Cannot demote the last remaining admin
Concurrent change detection : If multiple admins try to demote the last admin simultaneously, the operation is rolled back
Response
Updated user object (same structure as GET response)
Example Request
const response = await fetch ( '/api/admin/users' , {
method: 'PATCH' ,
headers: {
'Authorization' : `Bearer ${ adminToken } ` ,
'Content-Type' : 'application/json'
},
body: JSON . stringify ({
userId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' ,
userType: 'admin'
})
})
const { user } = await response . json ()
Error Cases
Self-Demotion Attempt (400)
{
"error" : "You cannot remove your own admin access."
}
Last Admin Protection (400)
{
"error" : "At least one admin user must remain."
}
Concurrent Change Rollback (409)
{
"error" : "Concurrent admin change detected. At least one admin must remain."
}
Implementation Detail
After demoting an admin, the endpoint performs a post-update verification (source:api/admin/users.js:362-376):
if ( targetIsAdmin && userType !== 'admin' ) {
const postUpdateAdminCount = await getAdminCount ( client )
if ( postUpdateAdminCount < 1 ) {
// Rollback: revert to admin
await client . auth . admin . updateUserById ( userId , {
app_metadata: { user_type: 'admin' }
})
return { error: 'Concurrent admin change detected. At least one admin must remain.' }
}
}
This prevents race conditions where multiple admins demote each other simultaneously.
POST /api/admin/users
Perform admin actions (currently: send password reset email).
Request Body
Action to perform. Options:
send_password_reset — Send password reset email to user
Password Reset Flow
Endpoint looks up user by ID
Validates user has an email address
Calls supabase.auth.resetPasswordForEmail() with admin-configured redirect URL
User receives email with reset link
Link redirects to app after password update
Redirect URL Resolution
The password reset redirect is determined by (in priority order):
X-Forwarded-Host or Host header from request → /app path
Supabase default (configured in project settings)
Example Request
const response = await fetch ( '/api/admin/users' , {
method: 'POST' ,
headers: {
'Authorization' : `Bearer ${ adminToken } ` ,
'Content-Type' : 'application/json'
},
body: JSON . stringify ({
action: 'send_password_reset' ,
userId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
})
})
const { success } = await response . json ()
Response
Error Cases
{
"error" : "User email not found."
}
{
"error" : "Unsupported action."
}
DELETE /api/admin/users
Delete a user account.
Query Parameters
UUID of the user to delete
Safety Guardrails
The following deletions are blocked :
Self-deletion : Admin cannot delete their own account from this panel
Last admin protection : Cannot delete the last remaining admin
Response
Example Request
const response = await fetch ( `/api/admin/users?userId= ${ userId } ` , {
method: 'DELETE' ,
headers: {
'Authorization' : `Bearer ${ adminToken } `
}
})
const { success } = await response . json ()
Error Cases
Self-Deletion Attempt (400)
{
"error" : "You cannot delete your own account from this panel."
}
Last Admin Protection (400)
{
"error" : "At least one admin user must remain."
}
{
"error" : "User not found."
}
Complete Example: User Management Dashboard
import { useState , useEffect } from 'react'
import { useSupabaseClient } from '@/hooks/useSupabase'
export function UserManagement () {
const [ users , setUsers ] = useState ([])
const [ stats , setStats ] = useState ( null )
const [ loading , setLoading ] = useState ( true )
const supabase = useSupabaseClient ()
const fetchUsers = async ( page = 1 , search = '' ) => {
setLoading ( true )
const { data : { session } } = await supabase . auth . getSession ()
const params = new URLSearchParams ({
page: page . toString (),
perPage: '25' ,
search ,
role: 'all' ,
confirmation: 'all'
})
const response = await fetch ( `/api/admin/users? ${ params } ` , {
headers: {
'Authorization' : `Bearer ${ session . access_token } `
}
})
const data = await response . json ()
setUsers ( data . users )
setStats ( data . stats )
setLoading ( false )
}
const promoteToAdmin = async ( userId ) => {
const { data : { session } } = await supabase . auth . getSession ()
const response = await fetch ( '/api/admin/users' , {
method: 'PATCH' ,
headers: {
'Authorization' : `Bearer ${ session . access_token } ` ,
'Content-Type' : 'application/json'
},
body: JSON . stringify ({ userId , userType: 'admin' })
})
if ( response . ok ) {
await fetchUsers ()
} else {
const { error } = await response . json ()
alert ( error )
}
}
const sendPasswordReset = async ( userId ) => {
const { data : { session } } = await supabase . auth . getSession ()
const response = await fetch ( '/api/admin/users' , {
method: 'POST' ,
headers: {
'Authorization' : `Bearer ${ session . access_token } ` ,
'Content-Type' : 'application/json'
},
body: JSON . stringify ({
action: 'send_password_reset' ,
userId
})
})
if ( response . ok ) {
alert ( 'Password reset email sent' )
}
}
const deleteUser = async ( userId ) => {
if ( ! confirm ( 'Are you sure you want to delete this user?' )) return
const { data : { session } } = await supabase . auth . getSession ()
const response = await fetch ( `/api/admin/users?userId= ${ userId } ` , {
method: 'DELETE' ,
headers: {
'Authorization' : `Bearer ${ session . access_token } `
}
})
if ( response . ok ) {
await fetchUsers ()
} else {
const { error } = await response . json ()
alert ( error )
}
}
useEffect (() => {
fetchUsers ()
}, [])
if ( loading ) return < div > Loading... </ div >
return (
< div >
< h1 > User Management </ h1 >
{ stats && (
< div className = "stats" >
< p > Total Users: { stats . totalUsers } </ p >
< p > Admins: { stats . adminUsers } </ p >
< p > Active (7d): { stats . recentlyActiveUsers7d } </ p >
</ div >
) }
< table >
< thead >
< tr >
< th > Email </ th >
< th > Role </ th >
< th > Last Sign In </ th >
< th > Actions </ th >
</ tr >
</ thead >
< tbody >
{ users . map ( user => (
< tr key = { user . id } >
< td > { user . email } </ td >
< td > { user . userType } </ td >
< td > { user . lastSignInAt ? new Date ( user . lastSignInAt ). toLocaleDateString () : 'Never' } </ td >
< td >
{ user . userType === 'member' && (
< button onClick = { () => promoteToAdmin ( user . id ) } > Promote to Admin </ button >
) }
< button onClick = { () => sendPasswordReset ( user . id ) } > Reset Password </ button >
< button onClick = { () => deleteUser ( user . id ) } > Delete </ button >
</ td >
</ tr >
)) }
</ tbody >
</ table >
</ div >
)
}
Best Practices
Respect cache TTL User list is cached for 15s — avoid excessive polling
Handle 409 conflicts Concurrent admin changes can trigger rollbacks — inform users to retry
Confirm destructive actions Always confirm before deleting users or changing roles
Monitor admin count Display admin count prominently to prevent accidental lockout
Overview — API authentication and error handling
Fanbasis — Payment webhook user provisioning