Building in Public#building-in-public

Admin Panel With Role-Based Access Control in Next.js

By Mohamed Abdi Guled

A two-tier role system for ShabelleHub's admin panel, enforced at the middleware, page, and database layers.

Admin Panel With Role-Based Access Control

ShabelleHub's admin panel is accessible only to authenticated users. Within that, some pages — user management, site settings, system configuration — are accessible only to admins. Editors can publish posts and manage content. Admins can do everything editors can, plus manage other users and change site-wide settings.

This is a two-tier role system. Implementing it correctly requires enforcement at every layer: the middleware, the page component, and the API route.


The Role Model

Two roles:

  • editor — can create, edit, and publish content. Cannot manage users or change site settings.
  • admin — all editor permissions, plus user management, role assignment, and settings.

Roles are stored in the profiles table in Supabase:

CREATE TABLE profiles (
  id UUID PRIMARY KEY REFERENCES auth.users(id),
  email TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT 'editor'
    CHECK (role IN ('editor', 'admin'))
);

The CHECK constraint prevents invalid role values at the database level. New signups default to editor — the DEFAULT 'editor' clause handles this automatically.


Layer 1: Middleware

Next.js middleware runs before any page renders. It is the first line of protection for authenticated routes:

// middleware.js
import { NextResponse } from 'next/server';
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';

export async function middleware(req) {
  const res = NextResponse.next();
  const supabase = createMiddlewareClient({ req, res });

  const {
    data: { session },
  } = await supabase.auth.getSession();

  const isAdminRoute = req.nextUrl.pathname.startsWith('/admin');
  const isLoginPage = req.nextUrl.pathname === '/admin/login';

  if (isAdminRoute && !isLoginPage && !session) {
    return NextResponse.redirect(new URL('/admin/login', req.url));
  }

  if (isLoginPage && session) {
    return NextResponse.redirect(new URL('/admin', req.url));
  }

  return res;
}

export const config = {
  matcher: ['/admin/:path*'],
};

Middleware checks for a valid Supabase session. Unauthenticated requests to any /admin/* route are redirected to /admin/login. Authenticated users visiting the login page are redirected to the dashboard.

Middleware does not check roles — only authentication. Role checking happens in the next layer.


Layer 2: Page Component

Each admin page wraps its content in AdminLayout, which accepts a requiredRole prop:

// pages/admin/users.js
export default function AdminUsersPage() {
  return (
    <AdminLayout title="Admin Users" requiredRole="admin">
      {/* page content */}
    </AdminLayout>
  );
}

AdminLayout fetches the current user's role and renders an access denied state if the role does not meet the requirement:

// components/admin/AdminLayout.js
export default function AdminLayout({ children, title, requiredRole = 'editor' }) {
  const { user, role, loading } = useAuth();

  if (loading) return <LoadingState />;

  if (!user) return null; // middleware handles redirect, this is a fallback

  const hasAccess = requiredRole === 'editor'
    ? ['editor', 'admin'].includes(role)
    : role === 'admin';

  if (!hasAccess) {
    return (
      <div>
        <h1>Access Denied</h1>
        <p>You need {requiredRole} access to view this page.</p>
      </div>
    );
  }

  return (
    <div>
      <AdminNav role={role} />
      <main>
        <h1>{title}</h1>
        {children}
      </main>
    </div>
  );
}

The useAuth hook returns the current user and their role from Supabase:

// lib/cms/useAuth.js
import { useEffect, useState } from 'react';
import { supabase } from '../supabase';

export function useAuth() {
  const [user, setUser]   = useState(null);
  const [role, setRole]   = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    supabase.auth.getSession().then(({ data: { session } }) => {
      setUser(session?.user ?? null);
      if (session?.user) {
        supabase
          .from('profiles')
          .select('role')
          .eq('id', session.user.id)
          .single()
          .then(({ data }) => {
            setRole(data?.role ?? 'editor');
            setLoading(false);
          });
      } else {
        setLoading(false);
      }
    });

    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, session) => {
        setUser(session?.user ?? null);
        if (!session) {
          setRole(null);
          setLoading(false);
        }
      }
    );

    return () => subscription.unsubscribe();
  }, []);

  return { user, role, loading };
}

Layer 3: API Routes

Client-side role checking is UX, not security. An attacker who bypasses the page-level check (by directly calling an API route) would have unrestricted access if the API route does not verify roles independently.

Every API route that requires admin access verifies the role server-side:

// pages/api/admin/users/[id]/role.js
import { createAdminClient } from '../../../../lib/supabaseAdmin';

export default async function handler(req, res) {
  if (req.method !== 'PATCH') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  const token = authHeader.replace('Bearer ', '');
  const supabase = createAdminClient();

  // Verify the token and get the requesting user
  const { data: { user }, error: authError } = await supabase.auth.getUser(token);
  if (authError || !user) {
    return res.status(401).json({ error: 'Invalid token' });
  }

  // Check the requesting user's role
  const { data: requester } = await supabase
    .from('profiles')
    .select('role')
    .eq('id', user.id)
    .single();

  if (requester?.role !== 'admin') {
    return res.status(403).json({ error: 'Admin access required' });
  }

  // Prevent self-demotion
  const { id: targetId } = req.query;
  if (targetId === user.id) {
    return res.status(400).json({ error: 'You cannot change your own role' });
  }

  const { role } = req.body;
  if (!['editor', 'admin'].includes(role)) {
    return res.status(400).json({ error: 'Invalid role' });
  }

  const { error } = await supabase
    .from('profiles')
    .update({ role })
    .eq('id', targetId);

  if (error) return res.status(500).json({ error: error.message });

  return res.status(200).json({ success: true });
}

Three checks in this route:

    • Token verification — the JWT is validated against Supabase Auth
    • Role verification — the requesting user's role is checked against the database
    • Self-demotion prevention — an admin cannot demote themselves (which would lock them out of user management)

The Admin Navigation

The navigation renders different items based on role:

// components/admin/AdminNav.js
const NAV_ITEMS = [
  { href: '/admin/posts',      label: 'Posts',      role: 'editor' },
  { href: '/admin/tools',      label: 'Tools',      role: 'editor' },
  { href: '/admin/media',      label: 'Media',      role: 'editor' },
  { href: '/admin/categories', label: 'Categories', role: 'editor' },
  { href: '/admin/tags',       label: 'Tags',       role: 'editor' },
  { href: '/admin/authors',    label: 'Authors',    role: 'admin'  },
  { href: '/admin/users',      label: 'Users',      role: 'admin'  },
  { href: '/admin/settings',   label: 'Settings',   role: 'admin'  },
];

export default function AdminNav({ role }) {
  const visible = NAV_ITEMS.filter(item =>
    item.role === 'editor' || role === 'admin'
  );

  return (
    <nav>
      {visible.map(item => (
        <Link key={item.href} href={item.href}>{item.label}</Link>
      ))}
    </nav>
  );
}

Editors see Posts, Tools, Media, Categories, and Tags. Admins see all of those plus Authors, Users, and Settings. The visibility is determined by the role prop passed from AdminLayout — the role that was verified against the database.


Why Three Layers

Each layer protects against a different failure mode:

| Layer | Protects against |

|-------|-----------------|

| Middleware | Unauthenticated users accessing any admin page |

| Page component | Authenticated editors accessing admin-only pages |

| API route | Direct API calls that bypass the page UI entirely |

A determined attacker can bypass the middleware (by forging a session cookie) and the page component (by calling the API directly). Only the API route check — which verifies the token and role against the database on every request — provides real security.

The middleware and page-level checks exist for UX: they prevent accidental navigation to restricted areas and provide a clear access-denied message rather than a cryptic API error. The API route check is the actual security control.


*This article is part of the ShabelleHub Building in Public series.*

📬 Get the latest AI tool reviews

Expert picks and comparisons, weekly. No spam.

← Back to Blog