Cambiar tema

Panel de administración con Next.js: guía completa de RBAC desde el diseño hasta la implementación

Easton editorial illustration: one admin console with a central role gate and three protected modules

Mirando el editor, el vigésimo tercer if (user.role === 'admin').

El proyecto de panel de administración que heredé el año pasado tenía el control de permisos del predecesor: comprobaciones de UserRole repartidas en más de veinte archivos. Cada vez que añadíamos un rol, había que buscar y modificar en todo el código. Una vez se nos olvidó un sitio y un usuario normal vio los informes financieros. A las dos de la madrugada me llamaron para arreglar el bug.

En aquella época revisé todas las formas de implementar un panel con Next.js y vi que a casi todos les duele el sistema de permisos. Saben que hay que usar RBAC, pero ¿cómo diseñar las tablas? ¿Cómo escribir el middleware? ¿Cómo generar menús dinámicos? ¿Tablas con Ant Design o shadcn/ui? No hay una respuesta estándar; tropezar es casi inevitable.

Tras dos semanas refactorizando el sistema de permisos, por fin pude dormir tranquilo. Este artículo recoge esa experiencia: desde el diseño de arquitectura RBAC hasta la implementación con middleware en Next.js 15, pasando por menús dinámicos y elección de componentes de tabla. Todo el paquete está aquí.

Diseño del modelo RBAC (por qué así)

Qué es RBAC y por qué lo usa todo el mundo

RBAC significa Role-Based Access Control (control de acceso basado en roles). La idea es muy simple: usuario → rol → permiso → recurso.

¿Por qué no asignar permisos directamente al usuario? Se puede, pero es un lío.

Imagina que entran cinco agentes de soporte. Si vinculas permisos usuario a usuario, configuras a cada uno: ver pedidos, responder comentarios, exportar informes… cinco veces. Con RBAC creas el rol «soporte», enlazas permisos al rol y asignas el rol al nuevo empleado. Una configuración, para siempre.

Lo crucial es el costo de mantenimiento. El product manager dice: «Los de soporte ya no pueden exportar informes, los datos son sensibles». Con RBAC cambias el rol una vez y todos los agentes se actualizan al instante. Con permisos por usuario, toca ir uno a uno; se te escapa uno y tienes un incidente en producción.

80%+
SaaS empresarial usa RBAC
Equilibrio entre flexibilidad y mantenibilidad; más simple que ABAC, más flexible que usuario-permiso directo

Más del 80 % de las aplicaciones SaaS empresariales en el extranjero usan RBAC o variantes. La razón es clara: flexibilidad y mantenibilidad en equilibrio. Más simple que ABAC (control basado en atributos), más flexible que vincular usuario y permiso directamente.

Cómo diseñar la granularidad sin agotarte

La granularidad de permisos es un arte. Si es muy gruesa, no controlas bien; si es muy fina, el mantenimiento se dispara.

Mi experiencia: tres niveles.

Permisos a nivel de página (rutas)

  • Lo más básico: si el usuario puede entrar en una página
  • Por ejemplo, solo administradores en /admin/users
  • Con middleware de Next.js; lo veremos en detalle

Permisos a nivel de módulo (menú)

  • Qué ítems muestra la barra lateral
  • El usuario no ve menús sin acceso; mejor experiencia
  • El frontend filtra la configuración del menú según permisos

Permisos a nivel de acción (botones)

  • Hasta botones concretos
  • Por ejemplo, «Eliminar usuario» solo para superadmin
  • Úsalo con cuidado; no hace falta en todos los botones

He visto lo peor: permiso por columna en cada tabla. Configuración infernal y rendimiento pésimo. Regla: no sobre-diseñes.

Para nombrar permisos recomiendo resource:action:

  • user:create — crear usuario
  • order:delete — eliminar pedido
  • report:export — exportar informe

Queda claro y facilita ordenar y buscar.

Diseño de tablas en la base de datos

Cuatro tablas centrales: usuarios, roles, permisos, recursos. Dos tablas de relación para muchos a muchos.

// Tabla de usuarios
User {
  id: string
  name: string
  email: string
  // Otros datos del usuario
}

// Tabla de roles
Role {
  id: string
  name: string  // "Administrador", "Soporte", "Operaciones"
  code: string  // "admin", "service", "operator"
  description: string
}

// Tabla de permisos
Permission {
  id: string
  name: string  // "Crear usuario"
  code: string  // "user:create"
  resource: string  // "user"
  action: string  // "create"
}

// Tabla de recursos (opcional, según complejidad)
Resource {
  id: string
  name: string  // "Gestión de usuarios"
  code: string  // "user"
  type: string  // "page" | "api" | "menu"
}

// Tabla usuario-rol
UserRole {
  userId: string
  roleId: string
}

// Tabla rol-permiso
RolePermission {
  roleId: string
  permissionId: string
}

¿Por qué no un roleId en User? Porque un usuario puede tener varios roles.

Zhang es «responsable técnico» y «revisor de contenido»; hay que fusionar permisos. La tabla intermedia lo resuelve; luego un JOIN en la consulta.

Si hay organigrama (departamento, puesto), añade Department y Position. No construyas todas las tablas el primer día: extiende según necesidad. Con Prisma u otro ORM, añadir campos y tablas después es sencillo.

Middleware de Next.js para proteger rutas (núcleo técnico)

Por qué hace falta middleware

Al principio metí lógica de permisos en cada página. Algo así:

// ❌ Mal ejemplo
export default function UsersPage() {
  const { user } = useSession()

  if (!user) {
    redirect('/login')
  }

  if (user.role !== 'admin') {
    return <div>Sin acceso</div>
  }

  return <div>Lista de usuarios...</div>
}

¿Parece bien? Problemas:

  1. Repetir en cada página, copy-paste infinito
  2. Fácil olvidar una página; riesgo de seguridad
  3. La página ya se renderizó; el usuario ve parpadeos
  4. En SSR la lógica se complica más

El middleware de Next.js lo resuelve: se ejecuta antes de la página, intercepta y trata todo de forma uniforme. Mejor rendimiento, código limpio, menos mantenimiento.

60-80%
Mejora de velocidad de respuesta
La validación en middleware es 60-80 % más rápida que a nivel de componente; menos renderizado innecesario

Implementación completa de middleware.ts

En Next.js 15 el middleware va en middleware.ts en la raíz. Aquí uso NextAuth; puedes usar Clerk u otro.

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'

// Mapa de permisos por ruta
const ROUTE_PERMISSIONS = {
  '/admin': ['admin'],  // Solo rol admin
  '/admin/users': ['admin', 'operator'],  // admin y operator
  '/dashboard': ['admin', 'operator', 'viewer'],  // tres roles
  '/reports': ['admin'],
} as const

// Rutas públicas sin login
const PUBLIC_ROUTES = ['/login', '/register', '/forgot-password']

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 1. Rutas públicas: pasar
  if (PUBLIC_ROUTES.includes(pathname)) {
    return NextResponse.next()
  }

  // 2. Obtener sesión
  const token = await getToken({
    req: request,
    secret: process.env.NEXTAUTH_SECRET,
  })

  // 3. Sin login: redirigir a login
  if (!token) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('from', pathname)  // Guardar origen para volver tras login
    return NextResponse.redirect(loginUrl)
  }

  // 4. Comprobar permiso de ruta
  const userRole = token.role as string
  const requiredRoles = ROUTE_PERMISSIONS[pathname as keyof typeof ROUTE_PERMISSIONS]

  if (requiredRoles && !requiredRoles.includes(userRole)) {
    // Sin permiso: 403
    return NextResponse.rewrite(new URL('/403', request.url))
  }

  // 5. OK: continuar
  return NextResponse.next()
}

// Matcher del middleware
export const config = {
  matcher: [
    // Todas las rutas excepto estáticos y API (ajusta según necesidad)
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

Puntos clave:

Mapa de rutas: constante clara; nueva ruta, una línea aquí.

Lista blanca pública: login, registro, etc., para evitar bucles (usuario quiere entrar y el middleware lo manda otra vez al login…).

Origen tras login: loginUrl.searchParams.set('from', pathname) importa. Tras bloqueo en /admin/users, tras login debe volver ahí, no al inicio.

Sin permiso: uso NextResponse.rewrite, no redirect; la URL no cambia pero el contenido es 403. También puedes redirigir a una página dedicada.

Coordinación frontend y backend

Importante: el middleware es la primera línea; la API debe validar otra vez.

La comprobación en frontend es optimización de UX. El código del navegador se puede alterar; con DevTools se saltan checks. La defensa real está en el servidor.

En Server Actions y rutas API de Next.js, valida permisos de nuevo:

// app/actions/deleteUser.ts
'use server'

import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

export async function deleteUser(userId: string) {
  // Validar permiso otra vez
  const session = await auth()

  if (!session || session.user.role !== 'admin') {
    throw new Error('No autorizado para esta acción')
  }

  // Eliminar
  await db.user.delete({ where: { id: userId } })

  return { success: true }
}

Doble capa:

  • Middleware frontend: feedback rápido, no mostrar páginas prohibidas
  • Validación backend: seguridad real ante peticiones maliciosas

Algunos equipos extraen la config de permisos a un módulo compartido; en monorepo encaja muy bien.

Rendimiento: dónde guardar permisos

¿Consultar la base en cada petición? No. Muy lento.

Dos enfoques:

Opción 1: permisos en el JWT

// Callbacks de NextAuth
callbacks: {
  async jwt({ token, user }) {
    if (user) {
      token.role = user.role
      token.permissions = user.permissions  // Lista de permisos en el token
    }
    return token
  }
}

Ventaja: el middleware no consulta BD. Inconveniente: cambios de permiso hasta que expire el token. Sirve si los permisos cambian poco.

Opción 2: caché Redis

Si cambian a menudo, cachea permisos en Redis. Rápido y casi en tiempo real; una dependencia más.

En mi proyecto usé la opción 1, token de 1 hora. Tras cambiar permisos, pedimos re-login. Los ajustes de permiso no son tan frecuentes.

Menús dinámicos y permisos (clave de UX)

Estructura de configuración del menú

Lo central: filtrar ítems según permisos del usuario. Primero config completa; luego filtro dinámico.

Mi config se ve así:

// config/menu.ts
import { Home, Users, Settings, FileText } from 'lucide-react'

export interface MenuItem {
  key: string
  label: string
  icon: React.ComponentType
  path?: string
  permission?: string  // Permiso requerido
  children?: MenuItem[]
}

export const MENU_CONFIG: MenuItem[] = [
  {
    key: 'dashboard',
    label: 'Panel',
    icon: Home,
    path: '/dashboard',
    // Sin permission: visible para cualquier usuario autenticado
  },
  {
    key: 'users',
    label: 'Usuarios',
    icon: Users,
    permission: 'user:read',
    children: [
      {
        key: 'users-list',
        label: 'Lista de usuarios',
        path: '/admin/users',
        permission: 'user:read',
      },
      {
        key: 'users-roles',
        label: 'Roles',
        path: '/admin/roles',
        permission: 'role:read',
      },
    ],
  },
  {
    key: 'reports',
    label: 'Informes',
    icon: FileText,
    path: '/reports',
    permission: 'report:read',
  },
  {
    key: 'settings',
    label: 'Configuración',
    icon: Settings,
    path: '/settings',
    permission: 'system:config',
  },
]

Notas:

¿Plano o árbol? Elijo árbol: relaciones claras, render recursivo. Plano con parentKey también vale.

permission opcional: sin él, todos los autenticados ven el ítem (p. ej. panel base).

Iconos como componentes, no strings: lucide-react, tipado y render sencillo.

Algoritmo de filtrado

Trampa: padre sin permiso pero hijo con permiso.

Sin user:read pero con role:read, ¿mostramos «Usuarios»?

Mi regla: si algún hijo es visible, mostramos el padre.

// lib/menu.ts
export function filterMenuByPermissions(
  menuItems: MenuItem[],
  userPermissions: string[]
): MenuItem[] {
  return menuItems
    .map((item) => {
      const filteredChildren = item.children
        ? filterMenuByPermissions(item.children, userPermissions)
        : undefined

      const hasPermission =
        !item.permission || userPermissions.includes(item.permission)

      const hasVisibleChildren =
        filteredChildren && filteredChildren.length > 0

      if (!hasPermission && !hasVisibleChildren) {
        return null
      }

      return {
        ...item,
        children: filteredChildren,
      }
    })
    .filter((item): item is MenuItem => item !== null)
}

Recursivo y claro. Pocos ítems de menú; rendimiento sobra.

Uso en componentes

Lo encapsulo en un Hook:

// hooks/usePermissionMenu.ts
'use client'

import { useMemo } from 'react'
import { useSession } from 'next-auth/react'
import { filterMenuByPermissions } from '@/lib/menu'
import { MENU_CONFIG } from '@/config/menu'

export function usePermissionMenu() {
  const { data: session } = useSession()

  const filteredMenu = useMemo(() => {
    if (!session?.user?.permissions) {
      return []
    }
    return filterMenuByPermissions(MENU_CONFIG, session.user.permissions)
  }, [session?.user?.permissions])

  return filteredMenu
}

useMemo evita recalcular en cada render si los permisos no cambian.

En la barra lateral:

// components/Sidebar.tsx
'use client'

import { usePermissionMenu } from '@/hooks/usePermissionMenu'

export function Sidebar() {
  const menu = usePermissionMenu()

  return (
    <nav>
      {menu.map((item) => (
        <MenuItem key={item.key} item={item} />
      ))}
    </nav>
  )
}

Limpio.

Resaltado de ruta y migas

Dos detalles: ruta activa y breadcrumb.

Activo con pathname:

'use client'

import { usePathname } from 'next/navigation'

function MenuItem({ item }: { item: MenuItem }) {
  const pathname = usePathname()
  const isActive = item.path === pathname

  return (
    <Link
      href={item.path || '#'}
      className={isActive ? 'bg-blue-100 text-blue-600' : 'text-gray-700'}
    >
      <item.icon />
      {item.label}
    </Link>
  )
}

Migas: ruta en el árbol del menú:

// lib/menu.ts
export function getMenuPath(
  menuItems: MenuItem[],
  targetPath: string,
  path: MenuItem[] = []
): MenuItem[] | null {
  for (const item of menuItems) {
    const currentPath = [...path, item]

    if (item.path === targetPath) {
      return currentPath
    }

    if (item.children) {
      const result = getMenuPath(item.children, targetPath, currentPath)
      if (result) return result
    }
  }

  return null
}

Devuelve la ruta desde la raíz; el componente de migas la pinta.

Rutas dinámicas (/admin/users/123): quita parámetros al comparar; ajusta según tu caso.

Tablas de datos: elección e implementación

Comparativa de tablas en 2026

Un panel sin tablas no es panel. Usuarios, pedidos, logs…

Probé las opciones habituales:

Ant Design Table

  • Pros: completo, documentación, comunidad china. Orden, filtro, paginación, filas expandibles, columnas fijas.
  • Contras: personalizar estilo cuesta; bundle grande; look fijo.
  • Para: paneles clásicos, equipos acostumbrados a Ant Design.

MUI DataGrid

  • Pros: Material Design, funciones enterprise (scroll virtual, reordenar columnas).
  • Contras: funciones avanzadas de pago (Pro), curva de aprendizaje, estilos difíciles.
  • Para: proyectos grandes con presupuesto.

shadcn/ui + TanStack Table

  • Pros: sin estilos impuestos, muy personalizable, TypeScript, rendimiento. Componentes bajo tu control.
  • Contras: más trabajo inicial en UI.
  • Para: proyectos modernos, flexibilidad, equipos que escriben código.

React-Admin

  • Pros: CRUD y permisos integrados, listo para usar.
  • Contras: acoplado al framework, poca flexibilidad.
  • Para: prototipos rápidos, CRUD estándar.
300%+
Crecimiento de shadcn/ui
Entre 2024-2026, shadcn/ui + TanStack Table creció más del 300 %; opción preferida en paneles modernos

Elegí shadcn/ui + TanStack Table: Tailwind CSS, integración natural, estilos a medida. La API de TanStack separa lógica y UI; cambiar la capa visual no obliga a reescribir la lógica.

Tabla con shadcn/ui en detalle

Data Table no es un componente cerrado: enseña a montarlo. Núcleo TanStack Table; shadcn/ui da la UI base.

Instalar:

npx shadcn@latest add table
npm install @tanstack/react-table

Crea un DataTable (código completo en el ejemplo del artículo).

Uso: defines columnas:

// app/admin/users/page.tsx
'use client'

import { ColumnDef } from '@tanstack/react-table'
import { DataTable } from '@/components/DataTable'
import { Button } from '@/components/ui/button'
import { usePermission } from '@/hooks/usePermission'

interface User {
  id: string
  name: string
  email: string
  role: string
}

const columns: ColumnDef<User>[] = [
  {
    accessorKey: 'name',
    header: 'Nombre',
  },
  {
    accessorKey: 'email',
    header: 'Correo',
  },
  {
    accessorKey: 'role',
    header: 'Rol',
  },
  {
    id: 'actions',
    cell: ({ row }) => {
      const user = row.original
      const { hasPermission } = usePermission()

      return (
        <div className="flex gap-2">
          {hasPermission('user:update') && (
            <Button size="sm" variant="outline">
              Editar
            </Button>
          )}
          {hasPermission('user:delete') && (
            <Button size="sm" variant="destructive">
              Eliminar
            </Button>
          )}
        </div>
      )
    },
  },
]

export default function UsersPage() {
  const users: User[] = [
    { id: '1', name: 'Zhang San', email: '[email protected]', role: 'admin' },
    { id: '2', name: 'Li Si', email: '[email protected]', role: 'user' },
  ]

  return (
    <div className="container mx-auto py-10">
      <DataTable columns={columns} data={users} />
    </div>
  )
}

En actions, usePermission controla qué botones ve cada usuario.

Paginación y filtros en servidor

El ejemplo anterior pagina en cliente; con muchos datos no sirve.

En producción, paginación en servidor. API típica:

// app/api/users/route.ts
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const page = parseInt(searchParams.get('page') || '0')
  const size = parseInt(searchParams.get('size') || '10')

  const [data, total] = await Promise.all([
    db.user.findMany({
      skip: page * size,
      take: size,
    }),
    db.user.count(),
  ])

  return Response.json({ data, total })
}

No olvides validar permisos en la API.

Buenas prácticas de permisos en tablas

Dos niveles:

Columnas: solo ciertos roles ven datos sensibles (teléfono, DNI)

const columns: ColumnDef<User>[] = [
  {
    accessorKey: 'name',
    header: 'Nombre',
  },
  ...(hasPermission('user:view-sensitive')
    ? [
        {
          accessorKey: 'phone',
          header: 'Teléfono',
        },
      ]
    : []),
]

Acciones: botones según permiso (ejemplo anterior con usePermission).

Encapsula un Hook reutilizable:

// hooks/usePermission.ts
'use client'

import { useSession } from 'next-auth/react'

export function usePermission() {
  const { data: session } = useSession()

  const hasPermission = (permission: string) => {
    return session?.user?.permissions?.includes(permission) ?? false
  }

  const hasAnyPermission = (permissions: string[]) => {
    return permissions.some((p) => hasPermission(p))
  }

  const hasAllPermissions = (permissions: string[]) => {
    return permissions.every((p) => hasPermission(p))
  }

  return { hasPermission, hasAnyPermission, hasAllPermissions }
}

Uso uniforme en componentes.

Producción: precauciones y buenas prácticas

Errores y antipatrones

Trampas que yo pisé:

❌ Error 1: solo permisos en frontend

Lo más peligroso. Todo corre en el navegador; DevTools cambia lo que quieras.

Un analista de la competencia se registró como usuario normal y cambió role: 'user' a role: 'admin'. Pasó la noche mirando nuestro panel. Al día siguiente el product manager no tenía buen color.

Correcto: frontend es UX; la API debe validar. Cada acción sensible en Server Actions o rutas API.

❌ Error 2: if (user.role === 'admin') en veinte archivos

Nuevo rol = buscar y reemplazar hasta la extenuación.

Correcto: config central + funciones de permiso. El Hook usePermission va en esa línea.

❌ Error 3: permisos hardcodeados

// Mal ejemplo
const ADMIN_USERS = ['[email protected]', '[email protected]']
if (ADMIN_USERS.includes(user.email)) {
  // lógica admin
}

Cambia el correo del jefe y toca desplegar de nuevo.

Correcto: permisos en base de datos, consulta dinámica. Roles y permisos configurables, no en código.

Optimización de rendimiento

Mal hecho, el sistema de permisos penaliza:

1. Permisos en el token

Como antes: rol y lista en JWT, sin consulta por petición.

2. Caché del menú filtrado

Filtrado recursivo: no recalcules en cada render. useMemo en usePermissionMenu.

3. Code splitting por ruta

App Router parte por ruta; cada página es un chunk. Muchas páginas sin splitting = primera carga lenta.

4. Menos comprobaciones redundantes

En una página de solo lectura, si ya entró, los botones pueden comprobar solo permisos incrementales (borrar, editar).

Checklist de seguridad

Antes de publicar:

✅ API valida permisos

  • Server Actions con check
  • Rutas API con check
  • Operaciones sensibles con segunda verificación (p. ej. borrar usuario)

✅ Evitar escalada de privilegios

  • El usuario no cambia su propio rol
  • No se auto-asigna permisos
  • Usuario de bajo nivel no accede a recursos altos

✅ Auditoría

  • Registrar acciones clave (crear usuario, borrar datos, cambiar permisos)
  • Quién, cuándo, qué
  • Logs append-only

✅ Sesión

  • Expiración razonable del token (1 h recomendada)
  • Cierre forzado de sesiones
  • Tras cambio de contraseña, invalidar tokens viejos

✅ Validación de entrada

  • Frontend y backend
  • Zod u otro esquema
  • SQL injection: ORM como Prisma ayuda

Monitorización y alertas

Publicar no es el final:

Métricas:

  • Pico de 403 → posible sondeo
  • Muchas peticiones de un usuario → bot o ataque
  • Cambios frecuentes de permisos → mala gestión

Alertas:

  • Acciones de superadmin en tiempo real
  • Email al cambiar config de permisos
  • SMS en login anómalo (ubicación, hora)

Sentry, DataDog, etc.

Lecciones reales

Caso 1: menú y ruta desalineados

Menú daba «Informes financieros» a operaciones; el middleware no. Entrada visible, clic → 403. Una semana de quejas.

Lección: una sola fuente de verdad para menú y rutas.

Caso 2: caché de permisos

Permisos en JWT, 24 h de vida. Revocamos acceso; el usuario siguió hasta que expiró el token.

Lección: operaciones sensibles no solo token; reconsultar BD o lista negra en Redis.

Caso 3: API sin permiso

Frontend impecable; una API sin check. Postman saltó todo.

Lección: la API es la última línea. Middleware o decoradores uniformes; no confíes en que cada dev recuerde.

En resumen: frontend = UX, backend = seguridad. Los dos, pero el backend manda.

Conclusión

El sistema de permisos no es magia negra, pero hacerlo bien cuesta.

Este artículo va de RBAC a middleware en Next.js 15, menús y tablas. Tres ideas:

  1. Diseño sin exceso: extiende cuando haga falta, no un modelo monstruo el día uno
  2. Capas: middleware en rutas, menú filtrado, botones según necesidad
  3. Doble defensa: frontend para experiencia, backend para seguridad

Si estás montando un panel:

  • Cuatro tablas RBAC (usuario, rol, permiso, recurso)
  • Middleware con config en constantes
  • Menú dinámico en un Hook
  • Tablas con shadcn/ui + TanStack Table

Dos semanas de refactor, pero ahora un rol nuevo es config en BD, cero código. «Auditor» lo monté en diez minutos.

Un buen sistema de permisos acelera al equipo. No esperes al incidente.

El proyecto open source HaloLight es referencia: Next.js 15 + React 19 + TypeScript + RBAC. Código sólido, muy útil para aprender.

Si te atas en la implementación, comenta. He pisado la mayoría de las trampas; lo que pueda ayudar, encantado.

Flujo de implementación RBAC en panel Next.js

Pasos completos para montar RBAC en un panel de administración con Next.js desde cero

⏱️ Estimated time: 120 min

  1. 1

    Step 1: Paso 1: Diseñar tablas RBAC

    Crear cuatro tablas centrales y dos de relación:

    **Tablas centrales**:
    • User: datos básicos del usuario
    • Role: roles (admin, operator, viewer, etc.)
    • Permission: permisos en formato resource:action (p. ej. user:create)
    • Resource (opcional): definición de recursos

    **Tablas de relación**:
    • UserRole: usuario-rol muchos a muchos
    • RolePermission: rol-permiso muchos a muchos

    **Convención de nombres**:
    Permisos en formato resource:action para gestión y búsqueda.

    **Extensibilidad**:
    Empezar simple; añadir Department y Position si hace falta organigrama.

    Usar Prisma u otro ORM para evolucionar el esquema.
  2. 2

    Step 2: Paso 2: Middleware de rutas en Next.js

    Crear middleware.ts en la raíz:

    **Mapa de permisos**:
    • Constante ROUTE_PERMISSIONS
    • Roles requeridos por ruta
    • PUBLIC_ROUTES (login, registro, etc.)

    **Lógica del middleware**:
    1. Si ruta pública, pasar
    2. getToken para sesión
    3. Sin login → redirect a login (guardar from)
    4. Comprobar rol vs ruta
    5. Sin permiso → 403

    **Rendimiento**:
    • Permisos en JWT
    • Evitar consulta BD por petición
    • Expiración del token ~1 hora

    **matcher**:
    Excluir estáticos y API; validar páginas.
  3. 3

    Step 3: Paso 3: Menú dinámico y filtrado

    Configuración y filtrado:

    **Estructura** (config/menu.ts):
    • Árbol de menú
    • key, label, icon, path, permission
    • permission opcional = visible para todos autenticados

    **Algoritmo** (lib/menu.ts):
    • filterMenuByPermissions recursivo
    • Padre visible si algún hijo lo está
    • Devolver árbol filtrado

    **Hook** (hooks/usePermissionMenu.ts):
    • useSession para permisos
    • useMemo para cachear
    • Sin recalcular si permisos iguales

    **Ruta activa y migas**:
    • usePathname
    • getMenuPath para breadcrumb
    • Rutas dinámicas: normalizar parámetros
  4. 4

    Step 4: Paso 4: shadcn/ui + TanStack Table

    Tabla reutilizable:

    **Instalación**:
    • npx shadcn@latest add table
    • npm install @tanstack/react-table

    **DataTable**:
    • useReactTable de TanStack
    • Orden, paginación, filtro
    • Tipos TypeScript

    **Permisos en tabla**:
    • Columnas sensibles: render condicional
    • Acciones: usePermission en botones
    • hasPermission, hasAnyPermission, hasAllPermissions

    **Paginación servidor**:
    • API con page y size
    • Prisma skip/take
    • Devolver data y total

    **Validación**:
    Permisos en tabla = UX; la API debe validar otra vez.
  5. 5

    Step 5: Paso 5: Validación en API y Server Actions

    Defensa en backend:

    **Server Actions**:
    • auth() al inicio
    • Comprobar rol y permisos
    • Error si no autorizado

    **Rutas API**:
    • getToken
    • Validar petición
    • Segunda verificación en acciones sensibles

    **Config unificada**:
    • Módulo compartido de permisos
    • Misma regla frontend y backend
    • Ideal en monorepo

    **Auditoría**:
    • Registrar crear, borrar, cambiar permisos
    • Operador, hora, detalle
    • Logs solo append
  6. 6

    Step 6: Paso 6: Rendimiento y seguridad

    Optimización en producción:

    **Rendimiento**:
    • Permisos en JWT
    • useMemo en menú
    • Code splitting por ruta
    • Evitar checks redundantes

    **Seguridad**:
    • Toda API con permiso
    • Anti escalada de privilegios
    • Expiración de token
    • Validación frontend y backend
    • Esquemas con Zod

    **Monitorización**:
    • Contar 403
    • Peticiones anómalas
    • Alertas superadmin
    • Email en cambios de permisos

    **Evitar**:
    • Solo frontend
    • Permisos hardcodeados
    • Menú y ruta distintos
    • Solo token en operaciones sensibles

FAQ

¿Por qué RBAC y no permisos directos por usuario?
La ventaja de RBAC es el costo de mantenimiento:

• **Gestión masiva**: cinco agentes de soporte = un rol, no cinco configs
• **Actualización única**: cambias el rol y todos los usuarios se sincronizan
• **Escalable**: un usuario puede tener varios roles; permisos se fusionan
• **Menos errores**: usuario-permiso directo deja sitios sin actualizar

Más del 80 % del SaaS empresarial usa RBAC por el equilibrio flexibilidad/mantenibilidad.
¿Middleware de Next.js vs permisos en componentes?
Roles distintos:

**Middleware (recomendado)**:
• Antes de la página, interceptación uniforme
• 60-80 % más rápido que en componente
• Código central, difícil olvidar una ruta
• Compatible con SSR

**En componente**:
• Tras render, parpadeos
• Repetir en cada página
• Copy-paste y omisiones

Recuerda: middleware es primera línea; la API debe validar otra vez.
¿Menú padre sin permiso pero hijo con permiso?
Estrategia «prioridad al hijo»:

**Lógica**:
• Si un hijo es visible, mostrar el padre
• El usuario accede a lo que sí puede

**Implementación**:
Algoritmo recursivo:
1. Filtrar hijos
2. Comprobar permiso del ítem
3. Sin permiso pero hijos visibles → conservar
4. Sin permiso ni hijos → eliminar

Equilibrio entre control y UX.
¿shadcn/ui + TanStack Table o Ant Design Table?
Según el proyecto:

**Ant Design Table**:
• Equipo ya usa Ant Design
• Desarrollo rápido, todo incluido
• Panel enterprise clásico
• Bundle grande aceptable

**shadcn/ui + TanStack Table**:
• Tailwind CSS
• Estilos muy personalizables
• Flexibilidad y rendimiento
• Más código propio

**Datos**:
Entre 2024-2026 el combo shadcn/ui + TanStack creció más del 300 % en paneles modernos.

Ambos son buenos; mira stack y equipo.
¿Cómo coordinar permisos frontend y backend?
Doble capa:

**Frontend (middleware + componentes)**:
• Objetivo: UX, feedback rápido
• Middleware en rutas, botones en UI
• Se puede saltar con DevTools; no es la defensa

**Backend (API + Server Actions)**:
• Objetivo: seguridad real
• Cada acción y ruta API
• Obligatorio en operaciones sensibles

**Config unificada**:
• Módulo compartido
• Mismas reglas
• Evita huecos menú/ruta

**Lección**: frontend perfecto, una API sin check → Postman lo explota.
¿Permisos en JWT o consulta a base de datos cada vez?
Por escenario:

**JWT (recomendado en muchos casos)**:
• Sin consulta en middleware
• Cambios tras expiración del token
• Permisos estables
• Expiración ~1 hora

**Redis**:
• Casi tiempo real
• Más infraestructura
• Permisos que cambian a menudo

**Consulta BD**:
• 100 % actual
• Lento por petición
• Solo casos especiales

**Híbrido**:
JWT + lista negra Redis de permisos revocados.
¿Checklist de seguridad antes de publicar?
Lista completa:

**API**:
• Server Actions con permiso
• Rutas API con permiso
• Segunda verificación en borrados, etc.

**Anti escalada**:
• No auto-cambio de rol
• No auto-asignación de permisos
• Recursos altos protegidos

**Auditoría y monitor**:
• Log de acciones críticas (inmutable)
• Alertas por 403 anómalos
• Superadmin en tiempo real
• Email al cambiar permisos

**Sesión**:
• Token con expiración (~1 h)
• Logout forzado
• Invalidar tokens tras cambio de contraseña

**Entrada**:
• Validación en ambos lados
• Zod
• ORM contra inyección SQL

Frontend = UX; backend = seguridad.

16 min de lectura · Publicado el: 7 ene 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog