FeaturesPricingBlogContactLog in
Log in
FeaturesPricingBlogContact
Back to blog

March 20, 2025

Building an Admin Panel with Next.js

Patterns for building a secure, well-structured admin panel — route protection, data tables, and role-based access with adminProcedure.

Building an Admin Panel with Next.js

On this page

StructureRoute protectionadminProcedureDataTableKey rules

Structure

The admin panel lives under app/admin/ and is protected at the middleware level. Each resource gets its own folder with a page.tsx and a columns.tsx:

app/admin/
  layout.tsx       ← sidebar + auth guard
  page.tsx         ← overview/dashboard
  users/
    page.tsx
    columns.tsx
  products/
    page.tsx
    columns.tsx
  orders/
    page.tsx
    columns.tsx

Route protection

Guard the entire admin tree in middleware:

// middleware.ts
if (request.nextUrl.pathname.startsWith("/admin")) {
  const session = await auth.api.getSession({ headers: request.headers });
  if (!session?.user || session.user.role !== "admin") {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }
}

adminProcedure

All admin data goes through tRPC using adminProcedure, which checks the session role server-side before executing:

// services/trpc/init.ts
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
  if (ctx.session.user.role !== "admin") {
    throw new TRPCError({ code: "FORBIDDEN" });
  }
  return next({ ctx });
});
// services/trpc/routers/admin/users.ts
export const usersRouter = router({
  list: adminProcedure.query(() => db.query.users.findMany()),
 
  delete: adminProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) =>
      db.delete(users).where(eq(users.id, input.id))
    ),
});

DataTable

Always use the shared <DataTable> component. Columns go in columns.tsx:

// app/admin/users/columns.tsx
import { ColumnDef } from "@tanstack/react-table";
 
export const columns: ColumnDef<User>[] = [
  { accessorKey: "name", header: "Name" },
  { accessorKey: "email", header: "Email" },
  {
    id: "actions",
    cell: ({ row }) => <UserActions user={row.original} />,
  },
];
// app/admin/users/page.tsx
const UsersPage = () => {
  const trpc = useTRPC();
  const { data, isPending } = useQuery(trpc.admin.users.list.queryOptions());
 
  if (isPending) return <Skeleton className="h-96 w-full" />;
 
  return <DataTable columns={columns} data={data ?? []} />;
};

Key rules

  • Never fetch directly from admin pages — always go through adminProcedure
  • Never share admin routers with public-facing procedures
  • Keep destructive actions behind AlertDialog for confirmation
  • Use Badge for status labels, DropdownMenu for row actions

Let's Get In Touch.

Your laboratory instruments should serve you, not the other way around. We're happy to help you.

Book a discovery call

Building the future, one project at a time.

Product

  • Features
  • Pricing
  • Blog

Company

  • Contact

Legal

  • Privacy
  • Terms
© 2026 Brand. All rights reserved.