Build This Now
Build This Now
Qu'est-ce que le code Claude ?Installer Claude CodeL'installateur natif de Claude CodeTon premier projet Claude Code
Claude Code v2.1.122 Release NotesClaude Code Dynamic Workflows : comment orchestrer 1 000 sous-agents sur une vraie codebaseBonnes pratiques Claude CodeMeilleures pratiques pour Claude Opus 4.7Claude Code sur un VPSIntégration GitRevue de code avec Claude CodeLes Worktrees avec Claude CodeClaude Code à distanceClaude Code ChannelsChannels, Routines, Teleport, DispatchTâches planifiées avec Claude CodePermissions Claude CodeLe mode auto de Claude CodeAjouter les paiements Stripe avec Claude CodeFeedback LoopsWorkflows TodoGestion des tâches dans Claude CodeTemplates de projetTarification et utilisation des tokens Claude CodeTarifs de Claude Code : ce que tu vas vraiment payerClaude Code Ultra ReviewConstruire une app Next.js avec Claude CodeClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeConstruire un MVP SaaS avec Claude CodeAjouter l'authentification avec Claude Code (Supabase Auth)Ajouter les emails transactionnels avec Claude Code (Resend + React Email)Construire une API type-safe avec Claude Code (oRPC + Zod)How to Add File Uploads With Claude Code (Supabase Storage)How to Run Background Jobs with Claude Code and InngestHow to Build an Admin Dashboard With Claude CodeHow to Add Full-Text Search With Claude Code (Postgres)Commerce agentique : comment construire une app que les agents IA peuvent payerClaude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Claude Code Headless Mode: The Definitive Guide to claude -pClaude Code Max Plan vs API Cost: Break-Even GuideClaude Code Prompt Caching: The Token Discount Most People Never Turn OnCombien coûte la création d'un SaaS avec Claude Code en 2026Run a Team of AI Agents in Parallel with Git WorktreesPrompt Injection in Coding Agents: How to Not Get Pwned
speedy_devvkoen_salo
Blog/Handbook/Workflow/How to Build an Admin Dashboard With Claude Code

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.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →
speedy_devvWritten by speedy_devvPublished Jul 14, 202611 min readHandbook hubWorkflow index

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.


Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

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 --noEmit

Then a clean production build confirms the App Router, the Server Components, and the "use cache" directive all compile the way you expect.

npm run build

Ask 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.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

Posted by @speedy_devv

Continue in Workflow

  • Commerce agentique : comment construire une app que les agents IA peuvent payer
    Un guide en français simple du commerce agentique en 2026 : ce que font x402, ACP et le Machine Payments Protocol, plus un pas-à-pas d'un week-end pour livrer une API payante que les agents IA peuvent acheter.
  • Bonnes pratiques Claude Code
    Cinq habitudes séparent les ingénieurs qui livrent avec Claude Code : les PRDs, les règles CLAUDE.md modulaires, les slash commands personnalisés, les resets /clear, et un état d'esprit d'évolution du système.
  • Le mode auto de Claude Code
    Un second modèle Sonnet examine chaque appel d'outil Claude Code avant qu'il s'exécute. Ce que le mode auto bloque, ce qu'il autorise, et les règles d'autorisation qu'il place dans tes paramètres.
  • Channels, Routines, Teleport, Dispatch
    Les quatre fonctionnalités Claude Code livrées par Anthropic en mars et avril 2026 qui transforment le CLI en une couche de coordination orientée événements, entre téléphone, web et desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • Ajouter l'authentification avec Claude Code (Supabase Auth)
    Ajoute l'inscription email/mot de passe, l'OAuth Google, les magic links, les routes protégées et la gestion des sessions à une app Next.js 16 avec Claude Code et Supabase Auth.

More from Handbook

  • Principes de base de l'agent
    Cinq façons de construire des agents spécialisés dans le code Claude : Sous-agents de tâches, .claude/agents YAML, commandes slash personnalisées, personas CLAUDE.md, et invites de perspective.
  • L'ingénierie du harness agent
    Le harness, c'est toutes les couches autour de ton agent IA sauf le modèle lui-même. Découvre les cinq leviers de contrôle, le paradoxe des contraintes, et pourquoi le design du harness détermine les performances de l'agent bien plus que le modèle.
  • Patterns d'agents
    Orchestrateur, fan-out, chaîne de validation, routage par spécialiste, raffinement progressif, et watchdog. Six formes d'orchestration pour câbler des sub-agents Claude Code.
  • Meilleures pratiques des équipes d'agents
    Patterns éprouvés pour les équipes d'agents Claude Code. Prompts de création riches en contexte, tâches bien calibrées, propriété des fichiers, mode délégué, et correctifs v2.1.33-v2.1.45.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

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.

On this page

What We Are Building
Brief Claude Before It Writes Anything
Storing Roles in a Profiles Table
The Impersonation-Safe RLS Function
Role-Gated Routes With proxy.ts
The Server-Side Role Gate
The Type-Safe API Layer
Users and Orders Table With Search and Pagination
Metrics Tiles Through the Type-Safe API
Run the Quality Gates
Where the $29 Code Kit Fits

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →