Building a Production Admin Dashboard: RBAC, Data Tables, and Theming

Implement role-based access control that gates both routes and UI elements, build the sidebar/dashboard shell layout, build a data table with pagination and filtering, and implement a dark/light/system theme system using CSS custom properties and a theme context.

Admin dashboards are the internal tools engineers spend the most hours inside, and the hardest part is rarely the charts — it's the scaffolding around them: who is allowed to see or do what, how the shell (sidebar, header, content area) stays consistent across dozens of pages, how a table with thousands of rows stays fast and filterable, and how the whole thing respects a user's light/dark preference without a flash of the wrong theme on load. This lesson builds those four pillars — role-based access control, the shell layout, a paginated/filterable data table, and a three-way theme system — as reusable primitives the rest of the dashboard's pages build on.

Role-based access control (RBAC) as a single source of truth

RBAC has two layers that must agree: gating entire routes (an 'Editor' shouldn't even load the /admin/users page) and gating individual UI elements within a page an authorized user CAN reach (a 'Viewer' can see the users table but shouldn't see a Delete button). Both layers should read from the same permission-checking function so a rule never has to be duplicated or, worse, drift out of sync between the route guard and a button's visibility check.

Model roles as a small closed set and permissions as capabilities derived from role — hasPermission(role, 'users:delete') — rather than scattering role === 'admin' checks throughout the codebase. That indirection is what lets you change 'which roles can delete users' in one place later without hunting through every component.

The shell layout: sidebar, header, content region

Nearly every page in the dashboard shares the same frame: a collapsible sidebar for navigation, a header with the current user and theme toggle, and a content region that changes per route. Building this once as a layout component (rendered via a nested route's <Outlet /> in React Router) means every page gets consistent chrome for free and only has to implement its own content.

The sidebar's active-link highlighting, its collapsed/expanded state, and its keyboard navigability (a real <nav> with a real list of links, not a div soup) all belong in this one component so they're solved exactly once.

Data tables: pagination, filtering, loading skeletons

A data table backing an admin page over a real dataset needs server-side pagination (never fetch all 50,000 rows to paginate client-side), column-level filtering that's reflected in the URL (so a filtered, paginated view is shareable and survives a refresh), and a loading skeleton that matches the table's actual shape so the layout doesn't jump between loading and loaded states.

Keep pagination and filter state as the single source of truth driving the data fetch (rather than fetching once and re-deriving pages from a giant in-memory array), and mirror that state into the URL's query string so back/forward navigation and shared links behave correctly.

Dark / light / system theme with CSS custom properties

The right architecture separates two concerns: the user's *preference* ('light' | 'dark' | 'system', persisted) and the *resolved theme* actually applied ('light' | 'dark', computed from preference and, when preference is 'system', the OS-level prefers-color-scheme media query). All visual colors live as CSS custom properties on :root (or a [data-theme] attribute selector), so components never hardcode a color — they reference var(--color-surface) and the same JSX renders correctly in both themes.

To avoid a flash of the wrong theme on first paint, the resolved theme must be applied before React hydrates — typically via a tiny inline script in the document head that reads the stored preference (or the media query) and sets the data-theme attribute synchronously, before any CSS paints.

Roles, permissions, and the theme preference type

ROLE_PERMISSIONS is the single source of truth — route guards and UI element checks both call hasPermission. Preference (what the user chose) and resolved theme (what's actually applied) are deliberately different types.

// rbac.ts
export type Role = "admin" | "editor" | "viewer";

export type Permission =
  | "users:view"
  | "users:edit"
  | "users:delete"
  | "reports:view"
  | "settings:edit";

const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
  admin: ["users:view", "users:edit", "users:delete", "reports:view", "settings:edit"],
  editor: ["users:view", "users:edit", "reports:view"],
  viewer: ["users:view", "reports:view"],
};

export function hasPermission(role: Role, permission: Permission): boolean {
  return ROLE_PERMISSIONS[role].includes(permission);
}

// theme.ts
export type ThemePreference = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";

Code Examples

One component handles both jobs: used at the route level (no fallback prop) it redirects unauthorized users away entirely; used inline around a button (with fallback={null}) it just hides that one element for a viewer who CAN reach the page but shouldn't see a destructive action. Both call sites route through the same hasPermission function, so the rule for who can delete users is defined exactly once.

const allowed = hasPermission(user.role, permission);

Common mistake: Writing {user.role === 'admin' && <button>Delete</button>} inline in a dozen places — when the business rule changes (say, editors can now also delete), you have to find and fix every one of those checks.

The layout is a real <nav> with a real <ul> of links — keyboard and screen-reader navigable for free, no ARIA gymnastics required. Every child route renders into <Outlet />, so the sidebar, header, and theme toggle are built exactly once and every admin page automatically gets consistent chrome. Colors are referenced via CSS custom properties, not hardcoded, so this same JSX is correct in both light and dark themes.

<main style={{ padding: 24, background: "var(--color-surface)", flex: 1 }}><Outlet /></main>

Page and search live in the URL's search params — the single source of truth — so the fetch effect re-runs whenever either changes, and the resulting view is shareable and survives a refresh. A cancelled flag guards against a slow, stale response overwriting state after a newer request has started (e.g., the user types a new search before the previous fetch resolves). The skeleton renders the same number of rows/columns as real data so the table doesn't visibly resize between loading and loaded.

}, [page, search]);

Common mistake: Fetching all rows once on mount and paginating/filtering them client-side with .slice() — this works for 200 rows and falls over (slow load, huge payload) for a real admin dataset with tens of thousands.

Preference ('system') and resolved theme ('dark', say) are tracked separately: the app persists what the user CHOSE, but always applies what that choice currently RESOLVES to, including live updates when preference is 'system' and the OS theme changes mid-session via the matchMedia change listener. The inline head script duplicates the resolution logic in plain JS so the correct data-theme is set synchronously before React ever mounts — eliminating the flash of the wrong theme that a React-only solution can't avoid, since React can't run before the page has parsed enough to paint.

document.documentElement.setAttribute("data-theme", resolveTheme(preference));

// RequirePermission.tsx
import { Navigate } from "react-router-dom";
import type { ReactNode } from "react";
import { hasPermission, type Permission } from "./rbac";
import { useAuth } from "./AuthContext";

interface RequirePermissionProps {
  permission: Permission;
  children: ReactNode;
  fallback?: ReactNode; // for inline UI gating; omit to redirect (route gating)
}

export function RequirePermission({ permission, children, fallback }: RequirePermissionProps) {
  const { user } = useAuth();
  if (!user) return <Navigate to="/login" replace />;

  const allowed = hasPermission(user.role, permission);
  if (!allowed) {
    return fallback !== undefined ? <>{fallback}</> : <Navigate to="/403" replace />;
  }
  return <>{children}</>;
}

// Route gating:
// <Route path="/admin/users" element={
//   <RequirePermission permission="users:view"><UsersPage /></RequirePermission>
// } />

// Inline UI element gating, same component, same rule:
// <RequirePermission permission="users:delete" fallback={null}>
//   <button onClick={() => deleteUser(user.id)}>Delete</button>
// </RequirePermission>
// DashboardLayout.tsx
import { NavLink, Outlet } from "react-router-dom";
import { useState } from "react";
import { ThemeToggle } from "./ThemeToggle";

const NAV_ITEMS = [
  { to: "/admin", label: "Overview", end: true },
  { to: "/admin/users", label: "Users" },
  { to: "/admin/reports", label: "Reports" },
  { to: "/admin/settings", label: "Settings" },
];

export function DashboardLayout() {
  const [collapsed, setCollapsed] = useState(false);

  return (
    <div style={{ display: "grid", gridTemplateColumns: collapsed ? "64px 1fr" : "220px 1fr", minHeight: "100vh" }}>
      <nav aria-label="Main navigation" style={{ background: "var(--color-surface-alt)" }}>
        <button type="button" onClick={() => setCollapsed((c) => !c)} aria-expanded={!collapsed}>
          {collapsed ? "Expand" : "Collapse"}
        </button>
        <ul>
          {NAV_ITEMS.map((item) => (
            <li key={item.to}>
              <NavLink
                to={item.to}
                end={item.end}
                style={({ isActive }) => ({
                  fontWeight: isActive ? 700 : 400,
                  color: isActive ? "var(--color-accent)" : "var(--color-text)",
                })}
  
…
// UsersTable.tsx
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";

interface UserRow {
  id: string;
  name: string;
  email: string;
  role: string;
}

interface PagedResult<T> {
  rows: T[];
  totalCount: number;
}

async function fetchUsers(page: number, pageSize: number, search: string): Promise<PagedResult<UserRow>> {
  const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), search });
  const res = await fetch(`/api/users?${params}`);
  if (!res.ok) throw new Error(`Failed to load users: ${res.status}`);
  return res.json();
}

function TableSkeleton({ rows }: { rows: number }) {
  return (
    <tbody>
      {Array.from({ length: rows }).map((_, i) => (
        <tr key={i} aria-hidden="true">
          {Array.from({ length: 3 }).map((__, j) => (
            <td key={j}>
              <div style={{ height: 14, background: "var(--color-skeleton)", borderRadius: 4 }} />
            </td>
          ))}
        </tr>
      ))}
    </tbody>
  );
}

export function UsersTable() {
  const [searchParams, setSearchParams] = useSearchParams();
  const page = Number(searchParams.get("page") ?? "1");
  const sea
…
// ThemeContext.tsx
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import type { ResolvedTheme, ThemePreference } from "./theme";

const STORAGE_KEY = "theme-preference";

function resolveTheme(preference: ThemePreference): ResolvedTheme {
  if (preference !== "system") return preference;
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

interface ThemeContextValue {
  preference: ThemePreference;
  resolvedTheme: ResolvedTheme;
  setPreference: (pref: ThemePreference) => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [preference, setPreferenceState] = useState<ThemePreference>(
    () => (localStorage.getItem(STORAGE_KEY) as ThemePreference | null) ?? "system",
  );
  const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => resolveTheme(preference));

  useEffect(() => {
    setResolvedTheme(resolveTheme(preference));
    document.documentElement.setAttribute("data-theme", resolveTheme(preference));
    localStorage.setItem(STORAGE_KEY, preference);

    if (preference 
…

Theme resolution: preference vs. applied theme

Preference is what the user chose and persists; resolved theme is what's actually rendered and can change live under 'system'.

ThemePreference (persisted)         ResolvedTheme (applied)
  "light" ───────────────────────────▶ "light"
  "dark"  ───────────────────────────▶ "dark"
  "system" ──▶ matchMedia("prefers-color-scheme: dark")
                    │ matches            │ doesn't match
                    ▼                    ▼
                 "dark"               "light"

  data-theme="<resolved>" on <html>  ──▶  CSS custom properties swap  ──▶  components repaint
  (set by inline <head> script pre-hydration, kept in sync by ThemeProvider afterward)

Important Rules

Route every access decision — page-level and element-level — through one hasPermission(role, permission) function.

Build the shell (sidebar/header) once as a layout route with <Outlet />, not copy-pasted into every page.

Drive paginated/filtered data fetches from URL search params so views are shareable and survive refresh.

Never client-side paginate a server-scale dataset fetched in full — page on the server.

Match loading skeletons to the real content's shape so layout doesn't jump between states.

Keep theme preference ('system' included) and resolved theme as separate pieces of state.

Apply the resolved theme via an inline pre-hydration script to avoid a flash of the wrong theme.

Reference all colors via CSS custom properties (var(--color-*)) — never hardcode a hex value in a component.

Where this shows up in production

This is the backbone of internal tools like Retool and Django admin, and of customer-facing admin panels in products like Stripe's dashboard or Linear's settings: a single RBAC check reused for both routing and UI, a persistent shell layout, server-paginated tables with URL-driven state, and a three-way theme system with a pre-hydration script — the exact technique GitHub, Vercel, and most modern SaaS dashboards use to avoid a theme flash.

{user.role === 'admin' && <button onClick={deleteUser}>Delete</button>}

<RequirePermission permission="users:delete" fallback={null}><button onClick={deleteUser}>Delete</button></RequirePermission>

Inlining role checks scatters the business rule across every component that needs it. When the rule changes (e.g. editors gain delete rights), every scattered check has to be found and updated individually, and it's easy to miss one.

const [theme, setTheme] = useState<'light' | 'dark'>('light'); // no system option, applied only after React mounts

Track ThemePreference ('light'|'dark'|'system') separately from ResolvedTheme, and set the resolved value via an inline <head> script before React hydrates.

Collapsing preference and resolved theme into one boolean-like value loses the 'system' option entirely, and applying it only after React mounts causes a visible flash of the wrong theme on every page load.

Why should route-level RBAC gating and inline UI-element gating (like hiding a Delete button) call the same hasPermission function instead of separate checks? A. React Router requires it for route matching to work B. It's faster at runtime than writing two separate conditions C. So the permission rule is defined in exactly one place; changing who can delete users updates both the route guard and every inline button check at once D. Because TypeScript cannot type two different permission checks

C. So the permission rule is defined in exactly one place; changing who can delete users updates both the route guard and every inline button check at once — Routing both checks through one function means the business rule for a given permission lives in a single place. If it's duplicated as separate role === 'admin' checks, a later rule change risks being applied in one place and forgotten in another.

Why does a flash-free dark/light theme require an inline script in the document head, rather than relying solely on a React ThemeProvider? A. React cannot read localStorage B. CSS custom properties only work with JavaScript frameworks C. React only runs after the page has parsed and hydrated, so a React-only solution paints the default theme first and then flips it, causing a visible flash; an inline head script runs synchronously before first paint D. The inline script is required by the CSS specification for prefers-color-scheme

C. React only runs after the page has parsed and hydrated, so a React-only solution paints the default theme first and then flips it, causing a visible flash; an inline head script runs synchronously before first paint — By the time a React component mounts and its effect runs, the browser has typically already painted with whatever theme attribute (or lack of one) was present in the initial HTML. Setting data-theme synchronously in a head script, before any paint, is what avoids the flash.

Why should a data table's current page number and search term be stored in URL search params rather than only in component state? A. URL params are automatically validated by TypeScript B. It makes the fetch request faster C. So the current filtered/paginated view is shareable via link and survives a page refresh or browser back/forward navigation D. React requires state to be serializable to strings

C. So the current filtered/paginated view is shareable via link and survives a page refresh or browser back/forward navigation — Component state alone is lost on refresh and can't be shared as a link. Mirroring page and search into the URL makes the view bookmarkable, shareable, and correctly restorable via browser navigation, while still driving the same data fetch.

Extend the dashboard: add a 'create/edit user' modal (with focus trapped inside while open, Escape to close, and focus returned to the triggering button on close) built as a reusable <Modal> component; add a bar or line chart to the Overview page summarizing user growth by month; add column-level filtering to the users table (e.g. filter by role) that also lives in the URL alongside page/search; and write at least two tests — one confirming hasPermission returns false for a 'viewer' role on 'users:delete', and one confirming the theme toggle updates the data-theme attribute on <html>. Hint: For the focus trap, on modal open capture document.activeElement to restore it on close, then keep a keydown listener on the modal container that, on Tab/Shift+Tab, cycles focus among the modal's focusable elements instead of letting it escape to the page behind it.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.