Web Engineering ·

Building a SaaS Admin Dashboard: Role-Based Views, Audit Trails, and Real-Time Updates in Next.js

A practical guide to the admin dashboard every SaaS product needs. Covers role-based access at the component level, audit log UIs, real-time updates with SSE and React Server Components, table pagination, and filter/search patterns in Next.js App Router with TypeScript.

Building a SaaS Admin Dashboard: Role-Based Views, Audit Trails, and Real-Time Updates in Next.js

Every SaaS product eventually needs an admin dashboard. Not the polished customer-facing UI, but the internal one: the place where your team manages users, reviews suspicious activity, handles support escalations, and watches live metrics. Teams often bolt this on late, and it shows. Routes that expose data to any authenticated user. No audit trail. A table that loads 50,000 rows into memory. A page that requires a full refresh to show updated state.

This guide builds it right from the start. We will cover role-based access at the component level, audit log persistence and display, real-time updates without WebSocket complexity, and the pagination and filter patterns that actually hold up at scale.

The stack: Next.js 14 with App Router, TypeScript, and a PostgreSQL-backed ORM. The patterns apply equally to Prisma, Drizzle, or raw queries.


Role-Based Access at the Component Level

Most guides stop at middleware: protect the route, redirect if unauthorized, done. That is necessary but not sufficient. In a real admin dashboard, different roles see different data even on the same page. A support agent can view a user’s subscription but not modify it. A billing admin can see invoices but not access the security audit log.

Start by defining roles and permissions as a typed constant:

// lib/permissions.ts
export const ROLES = ['superadmin', 'billing_admin', 'support_agent', 'viewer'] as const;
export type Role = typeof ROLES[number];

export const PERMISSIONS = {
  users: {
    read: ['superadmin', 'billing_admin', 'support_agent', 'viewer'],
    write: ['superadmin'],
    delete: ['superadmin'],
  },
  billing: {
    read: ['superadmin', 'billing_admin'],
    write: ['superadmin', 'billing_admin'],
  },
  audit_logs: {
    read: ['superadmin'],
  },
  impersonate: {
    execute: ['superadmin'],
  },
} as const satisfies Record<string, Record<string, readonly Role[]>>;

export type Resource = keyof typeof PERMISSIONS;
export type Action<R extends Resource> = keyof (typeof PERMISSIONS)[R];

export function can<R extends Resource>(
  role: Role,
  resource: R,
  action: Action<R>
): boolean {
  const allowed = PERMISSIONS[resource][action] as readonly Role[];
  return allowed.includes(role);
}

This gives you a single source of truth that TypeScript enforces. If you rename a permission, the compiler catches every call site.

Next, wrap it in a server-side helper and a client component gate:

// lib/auth.ts
import { cookies } from 'next/headers';
import { verifySession } from './session';
import type { Role } from './permissions';

export async function getAdminSession(): Promise<{ userId: string; role: Role } | null> {
  const token = cookies().get('admin_session')?.value;
  if (!token) return null;
  return verifySession(token);
}
// components/PermissionGate.tsx
'use client';

import type { Role, Resource, Action } from '@/lib/permissions';
import { can } from '@/lib/permissions';

interface Props<R extends Resource> {
  role: Role;
  resource: R;
  action: Action<R>;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

export function PermissionGate<R extends Resource>({
  role,
  resource,
  action,
  children,
  fallback = null,
}: Props<R>) {
  if (!can(role, resource, action)) return <>{fallback}</>;
  return <>{children}</>;
}

Usage in a page:

// app/admin/users/[id]/page.tsx
import { getAdminSession } from '@/lib/auth';
import { PermissionGate } from '@/components/PermissionGate';
import { DeleteUserButton } from './DeleteUserButton';

export default async function UserDetailPage({ params }: { params: { id: string } }) {
  const session = await getAdminSession();
  if (!session) redirect('/admin/login');

  const user = await fetchUser(params.id);

  return (
    <div>
      <UserProfile user={user} />
      <PermissionGate role={session.role} resource="users" action="delete">
        <DeleteUserButton userId={user.id} />
      </PermissionGate>
    </div>
  );
}

The gate is on the client for rendering, but the session fetch is on the server. Never pass the raw role as a prop from client components where it can be spoofed. The server component fetches the session, then passes the resolved role down.


Building an Audit Log

An audit log is not a nice-to-have. The moment you have multiple admins, you need to know who changed what and when. The schema is straightforward, but the details matter:

CREATE TABLE audit_events (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  actor_id    UUID NOT NULL,
  actor_role  TEXT NOT NULL,
  action      TEXT NOT NULL,
  resource    TEXT NOT NULL,
  resource_id TEXT,
  metadata    JSONB,
  ip_address  INET,
  user_agent  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX audit_events_actor_id_idx ON audit_events (actor_id);
CREATE INDEX audit_events_resource_id_idx ON audit_events (resource_id);
CREATE INDEX audit_events_created_at_idx ON audit_events (created_at DESC);

A typed helper to write events:

// lib/audit.ts
import { db } from './db';
import type { Role } from './permissions';

interface AuditEvent {
  actorId: string;
  actorRole: Role;
  action: string;
  resource: string;
  resourceId?: string;
  metadata?: Record<string, unknown>;
  ipAddress?: string;
  userAgent?: string;
}

export async function writeAuditEvent(event: AuditEvent): Promise<void> {
  await db.auditEvents.create({
    data: {
      actorId: event.actorId,
      actorRole: event.actorRole,
      action: event.action,
      resource: event.resource,
      resourceId: event.resourceId ?? null,
      metadata: event.metadata ?? {},
      ipAddress: event.ipAddress ?? null,
      userAgent: event.userAgent ?? null,
    },
  });
}

Call it in your server actions or API routes:

// app/admin/users/[id]/actions.ts
'use server';

import { getAdminSession } from '@/lib/auth';
import { can } from '@/lib/permissions';
import { writeAuditEvent } from '@/lib/audit';
import { headers } from 'next/headers';

export async function deleteUser(userId: string) {
  const session = await getAdminSession();
  if (!session || !can(session.role, 'users', 'delete')) {
    throw new Error('Unauthorized');
  }

  const h = headers();
  await db.users.delete({ where: { id: userId } });

  await writeAuditEvent({
    actorId: session.userId,
    actorRole: session.role,
    action: 'delete',
    resource: 'users',
    resourceId: userId,
    ipAddress: h.get('x-forwarded-for') ?? undefined,
    userAgent: h.get('user-agent') ?? undefined,
  });
}

The audit log UI is a filterable table. The key design question is whether to render it server-side with URL state or client-side with component state. Use URL state. It makes audit links shareable, back-button friendly, and server-rendered without hydration cost.

// app/admin/audit/page.tsx
import { getAdminSession } from '@/lib/auth';
import { can } from '@/lib/permissions';
import { fetchAuditEvents } from '@/lib/audit-queries';
import { AuditTable } from './AuditTable';

interface SearchParams {
  actor?: string;
  resource?: string;
  action?: string;
  from?: string;
  to?: string;
  page?: string;
}

export default async function AuditLogPage({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const session = await getAdminSession();
  if (!session || !can(session.role, 'audit_logs', 'read')) {
    redirect('/admin');
  }

  const page = Number(searchParams.page ?? 1);
  const pageSize = 50;

  const { events, total } = await fetchAuditEvents({
    actorId: searchParams.actor,
    resource: searchParams.resource,
    action: searchParams.action,
    from: searchParams.from ? new Date(searchParams.from) : undefined,
    to: searchParams.to ? new Date(searchParams.to) : undefined,
    page,
    pageSize,
  });

  return (
    <AuditTable
      events={events}
      total={total}
      page={page}
      pageSize={pageSize}
    />
  );
}

Real-Time Updates: SSE Over WebSockets for Dashboards

A dashboard that shows stale data is worse than no dashboard. You need to see when a user’s plan changes, when a payment fails, when a new alert fires. WebSockets are the default answer, but they add operational complexity (stateful connections, load balancer affinity, connection management). For a read-heavy admin dashboard, Server-Sent Events (SSE) are a better fit.

SSE gives you server-to-client streaming over a standard HTTP connection. It reconnects automatically. It works through most proxies. For an admin dashboard where updates flow one way (server to browser), it is the right tradeoff.

A route handler that streams events:

// app/admin/api/events/route.ts
import { NextRequest } from 'next/server';
import { getAdminSession } from '@/lib/auth';
import { subscribeToAdminEvents } from '@/lib/event-bus';

export async function GET(req: NextRequest) {
  const session = await getAdminSession();
  if (!session) {
    return new Response('Unauthorized', { status: 401 });
  }

  const encoder = new TextEncoder();
  let cleanup: (() => void) | undefined;

  const stream = new ReadableStream({
    start(controller) {
      const send = (event: string, data: unknown) => {
        const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
        controller.enqueue(encoder.encode(payload));
      };

      // Send a heartbeat every 30s to keep the connection alive
      const heartbeat = setInterval(() => {
        send('heartbeat', { ts: Date.now() });
      }, 30_000);

      cleanup = subscribeToAdminEvents(session.role, send);

      req.signal.addEventListener('abort', () => {
        clearInterval(heartbeat);
        cleanup?.();
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  });
}

On the client, a hook that subscribes and delivers typed events:

// hooks/useAdminEvents.ts
'use client';

import { useEffect, useCallback } from 'react';

type AdminEvent =
  | { type: 'user.updated'; payload: { userId: string; changes: Record<string, unknown> } }
  | { type: 'payment.failed'; payload: { userId: string; amount: number } }
  | { type: 'alert.fired'; payload: { alertId: string; message: string } };

export function useAdminEvents(onEvent: (event: AdminEvent) => void) {
  const handleEvent = useCallback(onEvent, [onEvent]);

  useEffect(() => {
    const es = new EventSource('/admin/api/events');

    const handlers: Record<string, (e: MessageEvent) => void> = {
      'user.updated': (e) => handleEvent({ type: 'user.updated', payload: JSON.parse(e.data) }),
      'payment.failed': (e) => handleEvent({ type: 'payment.failed', payload: JSON.parse(e.data) }),
      'alert.fired': (e) => handleEvent({ type: 'alert.fired', payload: JSON.parse(e.data) }),
    };

    for (const [event, handler] of Object.entries(handlers)) {
      es.addEventListener(event, handler);
    }

    return () => {
      es.close();
    };
  }, [handleEvent]);
}

For dashboards where SSE is too heavy (low-traffic internal tools, serverless with short-lived connections), polling with router.refresh() is often cleaner:

// hooks/usePollingRefresh.ts
'use client';

import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

export function usePollingRefresh(intervalMs: number) {
  const router = useRouter();

  useEffect(() => {
    const id = setInterval(() => router.refresh(), intervalMs);
    return () => clearInterval(id);
  }, [router, intervalMs]);
}

router.refresh() re-fetches the current page’s server components without a full navigation. It is the simplest real-time pattern in App Router and works well for metrics that update every 30-60 seconds.


Table Pagination and Filter Patterns

PatternWhen to useTradeoffs
Offset paginationSmall datasets, admin toolsSimple; expensive at deep offsets
Cursor paginationLarge datasets, infinite scrollEfficient; harder to implement jump-to-page
URL-based filtersShareable links, server renderingAll rendering on server; no client state drift
Client-side filtersPre-loaded small datasetsFast UX; not suitable for large tables
Server-side searchFull-text, large datasetsRequires index; latency per keystroke

For an admin dashboard, use offset pagination with URL-based filters. The data sets are typically under 100K rows, deep offsets are rare, and shareable links matter for support workflows.

Filter state lives in the URL. A filter bar component that pushes to the URL:

// components/AdminFilterBar.tsx
'use client';

import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { useCallback, useTransition } from 'react';

interface FilterBarProps {
  fields: Array<{ key: string; label: string; type: 'text' | 'select'; options?: string[] }>;
}

export function AdminFilterBar({ fields }: FilterBarProps) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const [isPending, startTransition] = useTransition();

  const updateFilter = useCallback(
    (key: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      if (value) {
        params.set(key, value);
      } else {
        params.delete(key);
      }
      params.delete('page'); // reset to page 1 on filter change
      startTransition(() => {
        router.push(`${pathname}?${params.toString()}`);
      });
    },
    [router, pathname, searchParams]
  );

  return (
    <div className="flex gap-3 flex-wrap" aria-busy={isPending}>
      {fields.map((field) =>
        field.type === 'select' ? (
          <select
            key={field.key}
            value={searchParams.get(field.key) ?? ''}
            onChange={(e) => updateFilter(field.key, e.target.value)}
            aria-label={field.label}
          >
            <option value="">All {field.label}</option>
            {field.options?.map((opt) => (
              <option key={opt} value={opt}>{opt}</option>
            ))}
          </select>
        ) : (
          <input
            key={field.key}
            type="text"
            placeholder={field.label}
            defaultValue={searchParams.get(field.key) ?? ''}
            onBlur={(e) => updateFilter(field.key, e.target.value)}
            onKeyDown={(e) => e.key === 'Enter' && updateFilter(field.key, (e.target as HTMLInputElement).value)}
          />
        )
      )}
    </div>
  );
}

Note the use of useTransition. When filters trigger a server re-render, the isPending flag lets you show a loading state without a skeleton flash on fast connections.


Dashboard Layout Architecture

The admin layout has a few specific requirements that the default Next.js nested layout handles well:

// app/admin/layout.tsx
import { getAdminSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { AdminNav } from '@/components/AdminNav';
import { AdminHeader } from '@/components/AdminHeader';

export default async function AdminLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getAdminSession();
  if (!session) redirect('/admin/login');

  return (
    <div className="flex h-screen overflow-hidden">
      <AdminNav role={session.role} />
      <div className="flex flex-col flex-1 overflow-hidden">
        <AdminHeader actorId={session.userId} role={session.role} />
        <main className="flex-1 overflow-y-auto p-6">
          {children}
        </main>
      </div>
    </div>
  );
}

The nav receives the role and hides items the current user cannot access. This is a UI convenience, not a security control. The actual permission check always happens in the page or server action.


Production Considerations

Audit log write failures should not block actions. If writeAuditEvent throws, the delete still happened. Wrap audit writes in a try/catch that logs to your error tracker, or use a background job queue that guarantees delivery.

Index your audit table early. A 10M-row audit log with no indexes on created_at will bring your admin queries to a crawl. Add partial indexes for high-cardinality columns you filter on frequently.

SSE connections count against your server’s file descriptor limit. At 100 concurrent admin users, each holding an SSE connection, that is 100 persistent connections per replica. This is manageable, but monitor it. If you deploy to a serverless platform with 30-second request limits, SSE will not work as described. Fall back to polling.

Never trust the role from a client component prop. A malicious user can forge a React prop. The session token must be verified on the server for every request that reads or writes sensitive data. The PermissionGate component controls rendering, but the server action re-checks independently.

Debounce search inputs before pushing to the URL. A filter that fires on every keystroke triggers a server re-render on every character. Debounce by 300ms for text fields, or use the onBlur and Enter pattern shown above.


Closing

The patterns here are not complex individually. The value is in combining them correctly: server-rendered pages with URL state for shareability, component-level permission gates backed by server-side checks, an audit log that captures the full context of every change, and real-time updates sized to actual operational load. Built this way, an admin dashboard grows with your product instead of becoming a maintenance liability the moment a second admin joins the team.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.