How to Build an Admin Dashboard With Claude Code
Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
An internal admin panel is where most SaaS teams cut corners, and it is exactly where a leaked route or a missing row-level check turns into a real incident. This guide builds one properly with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API. Every layer assumes the user is hostile until proven otherwise. By the end you have an /admin area only real admins can reach, backed by Postgres policies that hold even if the front end lies.
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
What We Are Building
The panel has four parts, and each one is a separate trust boundary. First, a role-gated /admin route group that redirects anyone who is not signed in and blocks anyone who is not an admin. Second, Supabase row-level security that lets admins read across every user's rows without ever borrowing their identity. Third, a users and orders table with server-side search and pagination so the page stays fast at ten thousand rows. Fourth, metrics tiles (total users, revenue, active orders) pulled through oRPC so the numbers are type-checked from Postgres to pixel.
The stack is Next.js 16 with the App Router, React 19, Tailwind CSS v4 with shadcn/ui, PostgreSQL via Supabase, and oRPC with Zod for the API layer. Claude Code writes most of it once you brief it correctly, which is the first step.
Brief Claude Before It Writes Anything
Claude's default admin dashboard is a client component that fetches from an unprotected route and checks the role in the browser. That is the exact anti-pattern you are trying to avoid, so tell it the rules up front. Add an admin section to your CLAUDE.md so every session starts with the constraints baked in.
## Admin Panel Rules
- All /admin routes live under app/(admin)/ with a server-side role check in the layout.
- The role check runs on the server. Never gate admin UI in the browser only.
- Route protection uses proxy.ts (Next.js 16), not middleware.ts.
- Every admin data read goes through an oRPC procedure guarded by requireAdmin middleware.
- RLS policies are the real security boundary. The UI gate is a convenience, not a wall.
- Admins read other users' rows via an is_admin() policy, never by swapping JWTs.With that in place, plan the feature before generating code. Plan mode forces Claude to map the files first, and you catch a wrong assumption in the plan instead of in three finished components.
claude --plan "build a role-gated /admin area: a users+orders table with search and pagination, and metrics tiles, all reading through oRPC with a requireAdmin guard"Storing Roles in a Profiles Table
Roles belong in the database, not in a hardcoded list of admin emails and not in client-editable metadata. Create a profiles table keyed to the Supabase auth user, with a role column that defaults to the least privilege. This runs as a migration.
create type public.user_role as enum ('user', 'admin');
create table public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
email text not null,
role public.user_role not null default 'user',
created_at timestamptz not null default now()
);
alter table public.profiles enable row level security;New signups need a profile row automatically, otherwise your first query returns nothing. A trigger on auth.users handles it so the application never has to remember.
create function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
insert into public.profiles (id, email)
values (new.id, new.email);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();The Impersonation-Safe RLS Function
Here is the piece most tutorials get wrong. To let admins read every row, the tempting move is to mint the target user's JWT and run queries as them. That is impersonation, it is invisible in your logs, and it means a bug in your admin panel executes with someone else's full permissions. Instead, admins stay themselves and get read access through an explicit policy.
The policy needs to know whether the current user is an admin. Checking that inside a policy on profiles would recurse, because reading profiles triggers the policy that reads profiles. A SECURITY DEFINER function breaks the loop by running with the definer's rights and bypassing RLS for that one lookup.
create function public.is_admin()
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
select exists (
select 1
from public.profiles
where id = auth.uid()
and role = 'admin'
);
$$;Now the policies. Regular users see only their own profile. Admins see everyone, but the query still runs as the admin, so auth.uid() in any audit trigger records the admin's real id, not the target's. That is what makes it impersonation-safe: the read is authorized, attributed, and reversible in the logs.
create policy "users read own profile"
on public.profiles for select
using (id = auth.uid());
create policy "admins read all profiles"
on public.profiles for select
using (public.is_admin());
create policy "users update own profile"
on public.profiles for update
using (id = auth.uid())
with check (id = auth.uid());The orders table follows the same shape. Owners read their own orders, admins read all of them, and nobody borrows an identity to do it.
create table public.orders (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles (id),
amount_cents integer not null,
status text not null default 'pending',
created_at timestamptz not null default now()
);
alter table public.orders enable row level security;
create policy "users read own orders"
on public.orders for select
using (user_id = auth.uid());
create policy "admins read all orders"
on public.orders for select
using (public.is_admin());If you ever do need genuine impersonation for support, keep it separate: a dedicated action that writes an impersonation_log row with the admin id, the target id, and a timestamp before it does anything. Silent token swaps are the thing to avoid, not impersonation as a deliberate, audited feature.
Role-Gated Routes With proxy.ts
The first UI gate is a redirect at the edge. In Next.js 16 this logic lives in proxy.ts at the project root, not the old middleware.ts. Claude still reaches for middleware.ts out of habit, which is why the CLAUDE.md note earns its place. The proxy checks for a session and bounces unauthenticated visitors away from /admin before a single component renders.
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "@supabase/ssr";
export async function proxy(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith("/admin")) {
return NextResponse.next();
}
const response = NextResponse.next();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.redirect(new URL("/login", request.url));
}
return response;
}
export const config = {
matcher: "/admin/:path*",
};The proxy proves someone is signed in. It deliberately does not check the role, because the role lives in a database the proxy should not be querying on every request. The role check belongs one layer deeper, in the admin layout, where it runs as a Server Component with a real database call.
The Server-Side Role Gate
The admin route group uses a layout that verifies the role on the server. If the caller is not an admin, it calls notFound() rather than redirecting, so a curious user cannot even confirm the /admin area exists. This is the wall. The proxy was the convenience.
// app/(admin)/admin/layout.tsx
import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) notFound();
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
if (profile?.role !== "admin") notFound();
return (
<div className="min-h-screen bg-background">
<header className="border-b px-6 py-4">
<h1 className="text-lg font-semibold">Admin</h1>
</header>
<main className="p-6">{children}</main>
</div>
);
}The Type-Safe API Layer
Every admin read now goes through oRPC so the query and the component share one contract. Start with a base builder that carries the authenticated context, then define a requireAdmin middleware that throws before any handler runs. Attach it once and every guarded procedure inherits the check.
// server/orpc/base.ts
import { os, ORPCError } from "@orpc/server";
import { createClient } from "@/lib/supabase/server";
export interface Context {
userId: string;
role: "user" | "admin";
}
export const base = os.$context<Context>();
export const requireAdmin = base.middleware(async ({ context, next }) => {
if (context.role !== "admin") {
throw new ORPCError("FORBIDDEN", { message: "Admins only" });
}
return next();
});
export const adminProcedure = base.use(requireAdmin);The context is built once per request from the Supabase session. This helper reads the user and their role so the middleware has something to check. If there is no session, the procedures never run.
// server/orpc/context.ts
import { createClient } from "@/lib/supabase/server";
import type { Context } from "./base";
export async function createContext(): Promise<Context> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { userId: "", role: "user" };
}
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
return { userId: user.id, role: profile?.role ?? "user" };
}Users and Orders Table With Search and Pagination
Search and pagination run on the server, never in the browser. Loading ten thousand profiles into React to filter client-side is the mistake that makes admin panels crawl. Instead the procedure takes a search string, a page number, and a page size, validates them with Zod, and returns exactly one page. The count: "exact" option gives you the total so the UI can render page numbers.
// server/orpc/admin.ts
import { z } from "zod";
import { adminProcedure } from "./base";
import { createClient } from "@/lib/supabase/server";
export const listUsers = adminProcedure
.input(
z.object({
search: z.string().trim().default(""),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
})
)
.handler(async ({ input }) => {
const supabase = await createClient();
const from = (input.page - 1) * input.pageSize;
const to = from + input.pageSize - 1;
let query = supabase
.from("profiles")
.select("id, email, role, created_at", { count: "exact" })
.order("created_at", { ascending: false })
.range(from, to);
if (input.search) {
query = query.ilike("email", `%${input.search}%`);
}
const { data, count, error } = await query;
if (error) throw error;
return {
rows: data ?? [],
total: count ?? 0,
page: input.page,
pageSize: input.pageSize,
};
});Because the RLS policy already lets admins read every profile, this query returns all matching rows for an admin and nothing extra for anyone else. The security is in the database, so even a bug that skipped the middleware could not leak another tenant's data. The oRPC guard is defense in depth, not the only line.
The page component calls the procedure on the server and reads its filters from the URL search params, which in Next.js 16 arrive as a promise you await. Keeping the state in the URL means a specific page of results is shareable and survives a refresh.
// app/(admin)/admin/users/page.tsx
import { createRouterClient } from "@orpc/server";
import { router } from "@/server/orpc/router";
import { createContext } from "@/server/orpc/context";
import { UsersTable } from "@/components/admin/users-table";
interface PageProps {
searchParams: Promise<{ q?: string; page?: string }>;
}
export default async function UsersPage({ searchParams }: PageProps) {
const { q, page } = await searchParams;
const client = createRouterClient(router, {
context: await createContext(),
});
const result = await client.listUsers({
search: q ?? "",
page: page ? Number(page) : 1,
pageSize: 20,
});
return <UsersTable {...result} search={q ?? ""} />;
}The table itself is a Client Component only because the search box and page links need interactivity. It never fetches data. It receives a finished page as props and pushes new query strings to the URL, which re-runs the Server Component above with fresh input.
// components/admin/users-table.tsx
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface Row {
id: string;
email: string;
role: string;
created_at: string;
}
interface Props {
rows: Row[];
total: number;
page: number;
pageSize: number;
search: string;
}
export function UsersTable({ rows, total, page, pageSize, search }: Props) {
const router = useRouter();
const [q, setQ] = useState(search);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
function go(nextPage: number, nextSearch: string) {
const params = new URLSearchParams();
if (nextSearch) params.set("q", nextSearch);
if (nextPage > 1) params.set("page", String(nextPage));
router.push(`/admin/users?${params.toString()}`);
}
return (
<div className="space-y-4">
<form
onSubmit={(e) => {
e.preventDefault();
go(1, q);
}}
className="flex gap-2"
>
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search by email"
className="max-w-xs"
/>
<Button type="submit">Search</Button>
</form>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Joined</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.email}</TableCell>
<TableCell>{row.role}</TableCell>
<TableCell>
{new Date(row.created_at).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages} ({total} users)
</span>
<div className="flex gap-2">
<Button
variant="outline"
disabled={page <= 1}
onClick={() => go(page - 1, search)}
>
Previous
</Button>
<Button
variant="outline"
disabled={page >= totalPages}
onClick={() => go(page + 1, search)}
>
Next
</Button>
</div>
</div>
</div>
);
}Metrics Tiles Through the Type-Safe API
The tiles at the top of the dashboard are aggregate counts, and they are a good fit for caching because they do not change on every request. This procedure returns total users, total revenue in cents, and a count of active orders. Marking the underlying read with the "use cache" directive means Next.js 16 serves the tiles from cache and revalidates on a schedule instead of hammering Postgres on every page view.
// server/orpc/admin.ts (continued)
export const getMetrics = adminProcedure.handler(async () => {
const supabase = await createClient();
const [{ count: userCount }, { data: orders }] = await Promise.all([
supabase.from("profiles").select("*", { count: "exact", head: true }),
supabase.from("orders").select("amount_cents, status"),
]);
const revenueCents = (orders ?? []).reduce(
(sum, o) => sum + o.amount_cents,
0
);
const activeOrders = (orders ?? []).filter(
(o) => o.status === "pending"
).length;
return {
users: userCount ?? 0,
revenueCents,
activeOrders,
};
});The dashboard page reads the metrics through the same server-side client and renders three tiles. Because the return type flows from the Zod-validated handler through oRPC, renaming revenueCents in the handler turns every tile that reads it into a compile error, not a blank card in production.
// app/(admin)/admin/page.tsx
import { createRouterClient } from "@orpc/server";
import { router } from "@/server/orpc/router";
import { createContext } from "@/server/orpc/context";
function Tile({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-2 text-3xl font-bold">{value}</p>
</div>
);
}
export default async function AdminDashboard() {
const client = createRouterClient(router, {
context: await createContext(),
});
const metrics = await client.getMetrics();
return (
<div className="grid gap-4 sm:grid-cols-3">
<Tile label="Total users" value={metrics.users.toLocaleString()} />
<Tile
label="Revenue"
value={`$${(metrics.revenueCents / 100).toLocaleString()}`}
/>
<Tile label="Active orders" value={metrics.activeOrders.toLocaleString()} />
</div>
);
}Run the Quality Gates
Before this ships, two commands decide whether it is done. A type check catches any mismatch between an oRPC handler and the component that reads it, which is exactly the class of bug that a type-safe API is supposed to make impossible.
npx tsc --noEmitThen a clean production build confirms the App Router, the Server Components, and the "use cache" directive all compile the way you expect.
npm run buildAsk Claude to run both and fix what breaks, and re-run until both are green. The admin panel is not shippable until the type check passes with zero errors and the build is clean.
claude "run tsc --noEmit and npm run build, fix any errors, and confirm both pass"There is one gate no command covers: RLS itself. Sign in as a normal user, open the network tab, and hit the admin procedures directly. Every one should come back forbidden or empty. If a plain user can read another user's orders, the policy is wrong, and no amount of front-end gating will save you.
Where the $29 Code Kit Fits
Claude Code writes this admin panel well when you brief it, plan first, and run the gates by hand every time. The $29 Code Kit (a one-time purchase, no subscription) is a harness that wires that discipline into a repeatable pipeline on top of Claude Code: it plans the feature, builds it, evaluates the output, tests it against a real browser, and runs the quality gates automatically, so a feature only exits with zero type errors, zero lint errors, and a clean build. It does not replace Claude Code, and Claude Code still needs a paid Anthropic plan to run. What the Kit removes is the part where you remember to run tsc, re-check the RLS, and re-read the diff on every single feature. For an admin panel, where a skipped check is a security hole, having that pipeline enforce the gates for you is the difference between shipping fast and shipping something you have to patch on Monday.
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
Posted by @speedy_devv
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
How to Run Background Jobs with Claude Code and Inngest
Move slow work off the request path. Build a durable Inngest job with retries, steps, and concurrency, triggered straight from your oRPC endpoints in Next.js 16.
How to Add Full-Text Search With Claude Code (Postgres)
Build fast in-app search without a new service. Postgres tsvector columns, GIN indexes, ranking, a debounced React UI, and RLS-safe results through oRPC.