Building Internal Admin Dashboards That Actually Work
Every SaaS product eventually needs an admin dashboard. Customer support needs to look up accounts. Operations needs to process exceptions. Finance needs to pull billing data. Founders need to see what's happening.
Admin dashboards often start as quick, rough tools: a few SQL queries, a Retool instance, or a hastily built page that wasn't designed to last. Then they become critical infrastructure that everyone depends on and no one wants to touch.
Here's how to build them correctly.
What admin dashboards need
The core requirements for a functional admin dashboard:
User lookup: Find a user by email, name, or ID. See their account details, subscription status, recent activity, and any flags on their account.
Account management: Reset passwords, change subscription plans, apply credits, merge accounts, impersonate users for debugging.
Data tables: Search, filter, sort, and paginate through records. Export to CSV for analysis.
Audit log: Every admin action should be logged with the admin's identity, timestamp, and what changed. This is essential for compliance and debugging.
Access control: Not every support person needs every admin action. Tier your admin permissions — basic lookup for support, account modification for senior support, data access for operations, full access for engineering.
The data table component
The most-used component in any admin dashboard. It needs to be reusable and powerful:
<!-- components/admin/DataTable.vue -->
<script setup generic="T extends Record<string, any>">
interface Column<T> {
key: keyof T
label: string
sortable?: boolean
render?: (value: T[keyof T], row: T) => string
}
const props = defineProps<{
columns: Column<T>[]
data: T[]
loading?: boolean
total?: number
page?: number
perPage?: number
}>()
const emit = defineEmits<{
sort: [key: string, direction: 'asc' | 'desc']
pageChange: [page: number]
rowClick: [row: T]
}>()
const sortKey = ref<string | null>(null)
const sortDir = ref<'asc' | 'desc'>('asc')
function handleSort(key: string) {
if (sortKey.value === key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = key
sortDir.value = 'asc'
}
emit('sort', key, sortDir.value)
}
</script>
The key design decision: keep sorting, pagination, and filtering server-side. Client-side sorting only works for small datasets. Admin tables often need to search hundreds of thousands of records.
Search and filtering
Admin search needs to be fast and flexible:
// server/api/admin/users.get.ts
export default defineEventHandler(async (event) => {
requireAdminAuth(event)
const query = getQuery(event)
const {
search,
plan,
status,
sortBy = 'created_at',
sortDir = 'desc',
page = 1,
perPage = 50
} = query
let dbQuery = supabase
.from('users')
.select('*, workspaces(name, plan)', { count: 'exact' })
if (search) {
dbQuery = dbQuery.or(
`email.ilike.%${search}%,name.ilike.%${search}%`
)
}
if (plan) {
dbQuery = dbQuery.eq('workspaces.plan', plan)
}
if (status) {
dbQuery = dbQuery.eq('status', status)
}
const { data, count } = await dbQuery
.order(sortBy as string, { ascending: sortDir === 'asc' })
.range((page - 1) * perPage, page * perPage - 1)
return { users: data, total: count }
})
User impersonation
One of the most useful admin capabilities: impersonating a user to see exactly what they see. Essential for debugging "I can't get the checkout to work" support tickets.
Implementation with Supabase:
// server/api/admin/impersonate.post.ts
export default defineEventHandler(async (event) => {
const admin = requireAdminAuth(event)
const { userId } = await readBody(event)
// Log the impersonation
await auditLog.create({
adminId: admin.id,
action: 'impersonate_user',
targetUserId: userId,
timestamp: new Date()
})
// Create a short-lived impersonation token
// Store it with the admin's identity so it can be detected and reverted
const token = createImpersonationToken(admin.id, userId)
return { token, redirectUrl: '/app' }
})
Crucially: impersonation sessions should be visually distinct in the UI (a banner: "Viewing as user name — Return to admin") and logged in the audit trail.
The audit log
Every action in an admin dashboard changes data. Track it all:
// server/utils/audit.ts
export async function auditLog(params: {
adminId: string
action: string
targetType: string
targetId: string
before?: Record<string, any>
after?: Record<string, any>
}) {
await db.adminAuditLog.create({
...params,
timestamp: new Date(),
ipAddress: getRequestIP(event),
userAgent: getRequestHeader(event, 'user-agent')
})
}
Admin audit logs are often required for SOC 2 compliance and are invaluable when a customer asks "what happened to my account?"
Access control
Not all admin users need all admin capabilities:
// server/middleware/admin-auth.ts
const ADMIN_PERMISSIONS = {
'support': ['view_users', 'view_accounts', 'reset_password'],
'senior_support': ['view_users', 'view_accounts', 'reset_password', 'modify_subscription'],
'operations': ['view_users', 'view_accounts', 'modify_subscription', 'apply_credits', 'export_data'],
'engineer': ['*'] // All permissions
}
export function requireAdminPermission(event: H3Event, permission: string) {
const admin = event.context.admin
const permissions = ADMIN_PERMISSIONS[admin.role] ?? []
if (!permissions.includes('*') && !permissions.includes(permission)) {
throw createError({ statusCode: 403, message: 'Insufficient permissions' })
}
}
When to use a third-party tool
Building a custom admin dashboard makes sense when:
- You need deep integration with your data model
- You have specific workflows that generic tools don't support
- You have a lot of users and need sophisticated search and filtering
Consider Retool, AppSmith, or Internal.io when:
- You need something working within days, not weeks
- Your admin requirements are standard (CRUD on your tables)
- You don't have the capacity to build and maintain custom admin tooling
The custom vs. third-party decision is about timeline vs. fit. Both are legitimate choices.