Back to Help Center
Advanced

Admin Portal

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

  1. Overview
  2. Database Setup
  3. Admin Layout & Protection
  4. Admin Dashboard
  5. User Management
  6. Subscription Management
  7. 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

  1. Always verify admin status on every request - don't rely on client-side checks
  2. Log all admin actions for audit trails
  3. Use rate limiting on admin endpoints
  4. Consider IP allowlisting for admin routes in production
  5. Implement 2FA for admin accounts
  6. 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_admin column
  • [ ] Set yourself as admin in the database
  • [ ] Create /lib/admin.ts helper
  • [ ] Create /app/admin/layout.tsx with 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)