SaaS

Building a SaaS Admin Dashboard from Scratch

Retool was too expensive and too limiting, so I built my own SaaS admin dashboard. Stats, user tables, audit logs — the full breakdown.

ZZ

Zeeshan Zakir

July 23, 20265 min readSaaS
Building a SaaS Admin Dashboard from Scratch

Around the time my SaaS crossed its first few dozen users, I noticed a pattern in my evenings: someone would email "can you extend my trial?" or "please delete my duplicate account," and I would open the Supabase dashboard and write SQL. By hand. At midnight. With production data.

The night I nearly ran an UPDATE without a WHERE clause was the night I decided to build a proper admin dashboard. I looked at Retool and similar tools first — genuinely good products — but per-seat pricing for what I needed, plus my data flowing through a third party, didn't sit right. Building it myself took one weekend for the core and another week of polish. Here's the whole thing.

Deciding what "admin dashboard" actually means

My first instinct was to build everything: feature flags, email campaign tools, revenue analytics, the works. I wrote the list, looked at it, and crossed off everything I hadn't personally needed in the previous month. What survived:

  • A stats overview (signups, active users, revenue-ish numbers)
  • A searchable user table
  • A user detail page with three or four actions (extend trial, deactivate, resend confirmation)
  • A log of every admin action

That's it. That list has barely grown since, which tells you something about admin dashboard feature creep.

Where it lives: a route group with a locked door

The dashboard is part of the same Next.js app, inside an (admin) route group with its own layout. The layout is the bouncer:

// app/(admin)/layout.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';

export default async function AdminLayout({ children }) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect('/login');

  const { data: profile } = await supabase
    .from('profiles')
    .select('is_admin')
    .eq('id', user.id)
    .single();

  if (!profile?.is_admin) redirect('/dashboard');
  return <div className="admin-shell">{children}</div>;
}

Two layers back this up. First, RLS policies on the database side use an is_admin() function, so even if the UI check somehow failed, the database wouldn't hand over other users' data. Second, nothing in the app can set is_admin — I flip it manually in SQL for exactly one row: mine. Defense in depth sounds enterprise-y until the first time it saves you.

The stats row: one function, not five queries

My first version fired five separate count queries from the page. It worked, but felt sluggish, so I moved the aggregation into Postgres where it belongs:

create or replace function admin_stats()
returns json as $$
  select json_build_object(
    'total_users', (select count(*) from profiles),
    'new_this_week', (select count(*) from profiles
      where created_at > now() - interval '7 days'),
    'active_subs', (select count(*) from subscriptions
      where status = 'active')
  );
$$ language sql security definer;

One RPC call, one round trip, all the numbers. The page renders as a Server Component, so the stats are just there on load — no spinners, no client fetching.

The user table: pagination is not optional

Here's the mistake I'll own publicly: version one fetched all users and paginated in React. At forty users, brilliant. I knew it wouldn't scale and did it anyway, and the table got noticeably slow far earlier than I expected. The rewrite does it properly — the URL holds the state, the server fetches one page:

const page = Number(searchParams.page ?? 1);
const search = searchParams.q ?? '';
const from = (page - 1) * 25;

let query = supabase
  .from('profiles')
  .select('id, email, full_name, created_at, plan', { count: 'exact' })
  .order('created_at', { ascending: false })
  .range(from, from + 24);

if (search) query = query.ilike('email', `%${search}%`);

Keeping page and search in searchParams instead of state means the browser back button works, and I can paste a filtered URL straight into a support reply to my future self.

Admin actions: server actions plus a paper trail

Each button on the user detail page calls a server action that does three things: re-verifies the caller is an admin (never trust that the UI already checked), performs the change, and writes an audit row.

'use server';

export async function extendTrial(userId: string, days: number) {
  const admin = await requireAdmin(); // throws if not

  await supabase.rpc('extend_trial', { p_user: userId, p_days: days });

  await supabase.from('audit_logs').insert({
    admin_id: admin.id,
    action: 'extend_trial',
    target_user: userId,
    details: { days },
  });
}

The audit log felt like ceremony when I added it. Three weeks later a user claimed their account was deactivated "randomly," and the log showed me deactivating it — in response to their own email, which they'd forgotten sending. Conversation over in one screenshot. Build the audit log on day one.

Also learned the hard way: destructive actions get a confirmation dialog. I once deactivated the wrong account because two users had nearly identical emails and my finger was faster than my eyes.

The chart that makes it feel real

One chart: signups per day for the last 30 days. SQL groups it, a small client component renders it with Recharts, everything else on the page stays server-rendered. I resisted adding more charts because the honest truth is I look at this one every morning and the others I sketched would have been decoration.

What it costs and what it's worth

Hosting cost of the admin panel: zero, it's part of the app. Time cost: a weekend plus a week of evenings, most of which was the table's edge cases. Compared to per-seat tooling, it paid for itself immediately — but the bigger win is mundane: support requests that used to mean "open SQL editor, breathe carefully" are now two clicks, logged, and reversible.

If you're building one, start embarrassingly small: stats, table, three actions, audit log. Everything else can wait until the evening it becomes your midnight SQL query.



Need help building this?

I offer full-stack development services for startups and product teams.

If you want a faster path from idea to shipped product, I can help with architecture, frontend systems, backend APIs, and launch-ready builds.

View Services

Share this post

Related posts

More practical reading from the blog to keep your momentum going.