Adding an Admin Portal
This guide walks you through adding admin functionality to your SaaS application. We'll cover database changes, protected routes, and building common admin features.
Table of Contents
- Overview
- Database Setup
- Admin Layout & Protection
- Admin Dashboard
- User Management
- Subscription Management
- Advanced Features
Overview
A basic admin portal typically includes:
| Feature | Description | |---------|-------------| | Dashboard | Key metrics (users, revenue, growth) | | User Management | View, search, and manage users | | Subscription Overview | See all subscriptions and their status | | Activity Logs | Track important events (optional) |
This guide implements a simple approach using an is_admin flag. For complex multi-tenant applications with multiple admin roles, consider a full RBAC (Role-Based Access Control) system instead.
Database Setup
Step 1: Add Admin Column to Profiles
Create a new migration file:
-- supabase/migrations/006_admin_role.sql
-- Add is_admin column to profiles
ALTER TABLE profiles
ADD COLUMN is_admin BOOLEAN DEFAULT false;
-- Create index for admin lookups
CREATE INDEX idx_profiles_is_admin ON profiles(is_admin) WHERE is_admin = true;
-- RLS policy: Only admins can view all profiles
CREATE POLICY "Admins can view all profiles"
ON profiles
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.is_admin = true
)
);
-- RLS policy: Only admins can view all subscriptions
CREATE POLICY "Admins can view all subscriptions"
ON subscriptions
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.is_admin = true
)
);
Step 2: Update Types
Add to /types/database.ts:
export interface Profile {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
stripe_customer_id: string | null;
is_admin: boolean; // Add this
created_at: string;
updated_at: string;
}
Step 3: Make Yourself an Admin
In Supabase SQL Editor:
UPDATE profiles
SET is_admin = true
WHERE email = 'your-email@example.com';
Admin Layout & Protection
Step 1: Create Admin Helper
Create /lib/admin.ts:
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export async function requireAdmin() {
const supabase = await createClient();
if (!supabase) {
redirect('/');
}
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/auth/login');
}
const { data: profile } = await supabase
.from('profiles')
.select('is_admin')
.eq('id', user.id)
.single();
if (!profile?.is_admin) {
redirect('/dashboard');
}
return user;
}
export async function isAdmin(): Promise<boolean> {
const supabase = await createClient();
if (!supabase) {
return false;
}
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return false;
}
const { data: profile } = await supabase
.from('profiles')
.select('is_admin')
.eq('id', user.id)
.single();
return profile?.is_admin ?? false;
}
Step 2: Create Admin Layout
Create /app/admin/layout.tsx:
import { requireAdmin } from '@/lib/admin';
import { AdminSidebar } from '@/components/admin/sidebar';
import { AdminHeader } from '@/components/admin/header';
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
await requireAdmin();
return (
<div className="flex min-h-screen">
<AdminSidebar />
<div className="flex-1">
<AdminHeader />
<main className="p-6">{children}</main>
</div>
</div>
);
}
Step 3: Create Admin Sidebar
Create /components/admin/sidebar.tsx:
import Link from 'next/link';
import {
LayoutDashboard,
Users,
CreditCard,
Settings,
ArrowLeft
} from 'lucide-react';
const adminNav = [
{ title: 'Dashboard', href: '/admin', icon: LayoutDashboard },
{ title: 'Users', href: '/admin/users', icon: Users },
{ title: 'Subscriptions', href: '/admin/subscriptions', icon: CreditCard },
{ title: 'Settings', href: '/admin/settings', icon: Settings },
];
export function AdminSidebar() {
return (
<aside className="w-64 border-r bg-muted/30 p-4">
<div className="mb-8">
<h2 className="text-lg font-semibold">Admin Portal</h2>
<p className="text-sm text-muted-foreground">Manage your SaaS</p>
</div>
<nav className="space-y-1">
{adminNav.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-muted"
>
<item.icon className="h-4 w-4" />
{item.title}
</Link>
))}
</nav>
<div className="mt-8 pt-8 border-t">
<Link
href="/dashboard"
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-muted"
>
<ArrowLeft className="h-4 w-4" />
Back to App
</Link>
</div>
</aside>
);
}
Step 4: Create Admin Header
Create /components/admin/header.tsx:
import { createClient } from '@/lib/supabase/server';
export async function AdminHeader() {
const supabase = await createClient();
const { data: { user } } = await supabase!.auth.getUser();
return (
<header className="border-b px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Admin</h1>
<div className="text-sm text-muted-foreground">
{user?.email}
</div>
</div>
</header>
);
}
Admin Dashboard
Create /app/admin/page.tsx:
import { createClient } from '@/lib/supabase/server';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Users, CreditCard, DollarSign, TrendingUp } from 'lucide-react';
async function getStats() {
const supabase = await createClient();
if (!supabase) {
return { users: 0, subscriptions: 0, activeSubscriptions: 0 };
}
// Get total users
const { count: userCount } = await supabase
.from('profiles')
.select('*', { count: 'exact', head: true });
// Get all subscriptions
const { count: subCount } = await supabase
.from('subscriptions')
.select('*', { count: 'exact', head: true });
// Get active subscriptions
const { count: activeCount } = await supabase
.from('subscriptions')
.select('*', { count: 'exact', head: true })
.eq('status', 'active');
return {
users: userCount ?? 0,
subscriptions: subCount ?? 0,
activeSubscriptions: activeCount ?? 0,
};
}
export default async function AdminDashboard() {
const stats = await getStats();
return (
<div>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.users}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.activeSubscriptions}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Subscriptions</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.subscriptions}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Conversion Rate</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.users > 0
? Math.round((stats.activeSubscriptions / stats.users) * 100)
: 0}%
</div>
</CardContent>
</Card>
</div>
{/* Add charts, recent activity, etc. here */}
</div>
);
}
User Management
Create /app/admin/users/page.tsx:
import { createClient } from '@/lib/supabase/server';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
async function getUsers() {
const supabase = await createClient();
if (!supabase) {
return [];
}
const { data: users } = await supabase
.from('profiles')
.select(`
*,
subscriptions (
status,
stripe_price_id
)
`)
.order('created_at', { ascending: false })
.limit(100);
return users ?? [];
}
export default async function UsersPage() {
const users = await getUsers();
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Users</h1>
<p className="text-sm text-muted-foreground">
{users.length} users
</p>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Joined</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell>{user.full_name || '—'}</TableCell>
<TableCell>
{user.subscriptions?.[0]?.status === 'active' ? (
<Badge>Active</Badge>
) : (
<Badge variant="secondary">Free</Badge>
)}
</TableCell>
<TableCell>
{new Date(user.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<a
href={`https://dashboard.stripe.com/customers/${user.stripe_customer_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
View in Stripe
</a>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
Adding Search
To add search functionality, create a client component:
// /components/admin/user-search.tsx
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Input } from '@/components/ui/input';
import { useState } from 'react';
export function UserSearch() {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState(searchParams.get('q') ?? '');
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const params = new URLSearchParams(searchParams);
if (query) {
params.set('q', query);
} else {
params.delete('q');
}
router.push(`/admin/users?${params.toString()}`);
};
return (
<form onSubmit={handleSearch} className="w-64">
<Input
placeholder="Search users..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</form>
);
}
Then update the users page to filter:
// In getUsers function
const query = searchParams?.q;
let usersQuery = supabase
.from('profiles')
.select(`*, subscriptions (status, stripe_price_id)`)
.order('created_at', { ascending: false })
.limit(100);
if (query) {
usersQuery = usersQuery.or(`email.ilike.%${query}%,full_name.ilike.%${query}%`);
}
Subscription Management
Create /app/admin/subscriptions/page.tsx:
import { createClient } from '@/lib/supabase/server';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { getPlanByPriceId } from '@/config/stripe';
async function getSubscriptions() {
const supabase = await createClient();
if (!supabase) {
return [];
}
const { data: subscriptions } = await supabase
.from('subscriptions')
.select(`
*,
profiles (
email,
full_name
)
`)
.order('created_at', { ascending: false })
.limit(100);
return subscriptions ?? [];
}
function getStatusColor(status: string) {
switch (status) {
case 'active':
return 'default';
case 'trialing':
return 'secondary';
case 'past_due':
return 'destructive';
case 'canceled':
return 'outline';
default:
return 'secondary';
}
}
export default async function SubscriptionsPage() {
const subscriptions = await getSubscriptions();
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Subscriptions</h1>
<p className="text-sm text-muted-foreground">
{subscriptions.length} subscriptions
</p>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Status</TableHead>
<TableHead>Current Period</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscriptions.map((sub) => {
const plan = getPlanByPriceId(sub.stripe_price_id);
return (
<TableRow key={sub.id}>
<TableCell>
<div>
<div className="font-medium">{sub.profiles?.email}</div>
<div className="text-sm text-muted-foreground">
{sub.profiles?.full_name}
</div>
</div>
</TableCell>
<TableCell>{plan?.name ?? 'Unknown'}</TableCell>
<TableCell>
<Badge variant={getStatusColor(sub.status)}>
{sub.status}
</Badge>
</TableCell>
<TableCell>
{new Date(sub.current_period_end).toLocaleDateString()}
</TableCell>
<TableCell>
<a
href={`https://dashboard.stripe.com/subscriptions/${sub.stripe_subscription_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
Manage in Stripe
</a>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}
Advanced Features
Once you have the basics, consider adding:
1. User Impersonation (for support)
// API route to generate impersonation link
// WARNING: Implement carefully with audit logging
export async function POST(request: Request) {
const { userId } = await request.json();
// Verify admin
await requireAdmin();
// Log the impersonation attempt
await logAdminAction('impersonate', { targetUserId: userId });
// Generate a short-lived token
// Redirect admin to a special auth route that signs them in as the user
}
2. Activity/Audit Logs
Create an admin_logs table:
CREATE TABLE admin_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id UUID REFERENCES profiles(id),
action VARCHAR(100) NOT NULL,
target_type VARCHAR(50),
target_id UUID,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
3. Feature Flags
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) UNIQUE NOT NULL,
enabled BOOLEAN DEFAULT false,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
4. Announcements/Notifications
CREATE TABLE announcements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
type VARCHAR(50) DEFAULT 'info',
active BOOLEAN DEFAULT true,
starts_at TIMESTAMP WITH TIME ZONE,
ends_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
5. Revenue Metrics from Stripe
For accurate MRR, ARR, and churn metrics, use the Stripe API directly:
import { stripe } from '@/lib/stripe/server';
async function getRevenueMetrics() {
// Get MRR from active subscriptions
const subscriptions = await stripe.subscriptions.list({
status: 'active',
limit: 100,
});
const mrr = subscriptions.data.reduce((total, sub) => {
const item = sub.items.data[0];
const amount = item.price.unit_amount ?? 0;
const interval = item.price.recurring?.interval;
// Normalize to monthly
if (interval === 'year') {
return total + (amount / 12);
}
return total + amount;
}, 0);
return {
mrr: mrr / 100, // Convert from cents
arr: (mrr / 100) * 12,
activeSubscriptions: subscriptions.data.length,
};
}
Security Considerations
- Always verify admin status on every request - don't rely on client-side checks
- Log all admin actions for audit trails
- Use rate limiting on admin endpoints
- Consider IP allowlisting for admin routes in production
- Implement 2FA for admin accounts
- Never expose admin routes in sitemap or robots.txt
File Structure Summary
After implementing, your admin files should look like:
/app/admin/
├── layout.tsx # Admin layout with protection
├── page.tsx # Dashboard with stats
├── users/
│ └── page.tsx # User management
├── subscriptions/
│ └── page.tsx # Subscription overview
└── settings/
└── page.tsx # Admin settings
/components/admin/
├── sidebar.tsx # Admin navigation
├── header.tsx # Admin header
└── user-search.tsx # Search component
/lib/
└── admin.ts # Admin utilities
/supabase/migrations/
└── 006_admin_role.sql # Admin column migration
Quick Start Checklist
- [ ] Run the migration to add
is_admincolumn - [ ] Set yourself as admin in the database
- [ ] Create
/lib/admin.tshelper - [ ] Create
/app/admin/layout.tsxwith protection - [ ] Create admin sidebar and header components
- [ ] Build dashboard page with basic stats
- [ ] Build users page with table
- [ ] Build subscriptions page
- [ ] Add to robots.txt:
Disallow: /admin/ - [ ] Test admin access with non-admin user (should redirect)