Build This Now
Build This Now
O que é o Código Claude?Instalar o Claude CodeInstalador Nativo do Claude CodeO Teu Primeiro Projeto com Claude Code
Claude Code v2.1.122 Release NotesClaude Code Dynamic Workflows: Como Orquestrar 1.000 Subagentes Num Codebase RealMelhores Práticas do Claude CodeBoas Práticas para o Claude Opus 4.7Claude Code num VPSIntegração GitRevisão de Código com ClaudeWorktrees no Claude CodeControle Remoto do Claude CodeChannels do Claude CodeChannels, Routines, Teleport, DispatchTarefas Agendadas no Claude CodePermissões do Claude CodeModo Auto do Claude CodeAdicionar Pagamentos Stripe Com o Claude CodeFeedback LoopsFluxos de Trabalho com TodosTarefas no Claude CodeTemplates de ProjetoPreços e Consumo de Tokens no Claude CodePreços do Claude Code: O Que Vais Mesmo PagarClaude Code Ultra ReviewConstruir Uma App Next.js Com o Claude CodeClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeComo Construir um MVP de SaaS Com o Claude CodeAdicionar Autenticação Com o Claude Code (Supabase Auth)Adicionar Email Transacional Com o Claude Code (Resend + React Email)Construir Uma API Type-Safe Com o 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)Comércio Agêntico: Como Construir uma App Que Agentes de IA Podem PagarClaude 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 OnQuanto Custa Construir um SaaS com Claude Code em 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.

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →
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.


Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →

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.

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →

Posted by @speedy_devv

Continue in Workflow

  • Comércio Agêntico: Como Construir uma App Que Agentes de IA Podem Pagar
    Um guia em português simples sobre comércio agêntico em 2026: o que fazem o x402, o ACP e o Machine Payments Protocol, mais um passo a passo de fim de semana para lançar uma API paga que agentes de IA podem comprar.
  • Melhores Práticas do Claude Code
    Cinco hábitos separam os engenheiros que entregam com Claude Code: PRDs, regras modulares em CLAUDE.md, slash commands personalizados, resets com /clear e uma mentalidade de evolução do sistema.
  • Modo Auto do Claude Code
    Um segundo modelo Sonnet revê cada chamada de ferramenta do Claude Code antes de ser executada. O que o modo auto bloqueia, o que permite e as regras de permissão que cria nas tuas definições.
  • Channels, Routines, Teleport, Dispatch
    As quatro funcionalidades de Claude Code que a Anthropic lançou em março e abril de 2026 e que transformam a CLI numa camada de coordenação orientada a eventos entre telemóvel, web e 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.
  • Adicionar Autenticação Com o Claude Code (Supabase Auth)
    Adiciona registo por email/password, Google OAuth, magic links, rotas protegidas e gestão de sessões a uma app Next.js 16 usando o Claude Code e o Supabase Auth.

More from Handbook

  • Fundamentos do agente
    Cinco maneiras de criar agentes especializados no Código Claude: Sub-agentes de tarefas, .claude/agents YAML, comandos de barra personalizados, personas CLAUDE.md e prompts de perspetiva.
  • Engenharia de Harness para Agentes
    O harness é cada camada ao redor do seu agente de IA, exceto o modelo em si. Aprenda os cinco pontos de controle, o paradoxo das restrições, e por que o design do harness determina o desempenho do agente mais do que o modelo.
  • Padrões de Agentes
    Orchestrator, fan-out, cadeia de validação, routing especializado, refinamento progressivo e watchdog. Seis formas de orquestração para ligar sub-agentes no Claude Code.
  • Boas Práticas para Equipas de Agentes
    Padrões testados em produção para Equipas de Agentes Claude Code. Prompts de criação ricos em contexto, tarefas bem dimensionadas, posse de ficheiros, modo delegado, e correções das versões v2.1.33-v2.1.45.

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →

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

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →