Cambiar tema

Esqueleto de panel con shadcn/ui: mejores prácticas de Sidebar + Layout

Easton editorial illustration: developer problem-solving desk

Guía paso a paso para montar un esqueleto de panel extensible: del componente Sidebar a la integración con Layout de Next.js, con código listo para usar.


La semana pasada empecé un panel de administración y lo primero que probé fue shadcn/ui. Antes usé Ant Design y MUI; personalizar estilos era pesado — muchas capas de override o quedar atado al design system del framework.

shadcn/ui va por otro camino: copy-paste, el código vive en tu proyecto y lo cambias cuando quieras. Tras un par de semanas, el Sidebar junto al App Router de Next.js deja un esqueleto de backend muy limpio.

Este artículo ordena ese proceso: de cero a un layout que escala.


1. ¿Por qué Sidebar de shadcn/ui?

Lo que no funcionaba antes

Con Ant Design Pro arrancabas rápido, pero al crecer el proyecto redondear la barra lateral era un lío. MUI exige dominar Material Design para sacarle partido al theming.

La propuesta de shadcn/ui

  • Copy-paste: sin caja negra en node_modules
  • Radix UI: accesibilidad y teclado resueltos
  • Tailwind CSS: estilos = clases, sin guerras de especificidad

Muchos equipos migran desde MUI porque quieren control, no una plantilla rígida.

Cuándo encaja

  • Paneles de administración medianos
  • Consolas SaaS
  • Herramientas internas u operaciones

No es una plantilla completa; es un esqueleto flexible.


2. Arquitectura del Sidebar

Componentes principales

SidebarProvider   // Contexto de estado, envuelve la app
Sidebar          // Contenedor lateral
SidebarHeader    // Zona superior fija (logo)
SidebarContent   // Área con scroll (menú)
SidebarGroup     // Agrupación de menú
SidebarMenu      // Lista de menú
SidebarMenuItem  // Ítem
SidebarMenuButton // Botón (compatible con Link)
SidebarFooter    // Zona inferior (usuario)
SidebarTrigger   // Botón colapsar/expandir
SidebarInset     // Wrapper del contenido principal

Relación:

SidebarProvider
├── Sidebar
│   ├── SidebarHeader
│   ├── SidebarContent
│   │   └── SidebarGroup
│   │       └── SidebarMenu
│   │           └── SidebarMenuItem
│   │               └── SidebarMenuButton
│   └── SidebarFooter
└── SidebarInset
    └── {children}

Estado

No controlado (recomendado):

<SidebarProvider defaultOpen={true}>
  <Sidebar />
</SidebarProvider>

Controlado:

const [open, setOpen] = useState(true);

<SidebarProvider open={open} onOpenChange={setOpen}>
  <Sidebar />
</SidebarProvider>

Usa controlado solo si otro módulo (p. ej. ajustes) debe manejar el Sidebar.

Responsive

  • Escritorio: barra fija a la izquierda, colapsable con SidebarTrigger
  • Móvil: Sheet (cajón) al pulsar el trigger

La lógica va dentro del componente; tú solo configuras SidebarProvider.


3. Integración con Layout de Next.js

3.1 Estructura del proyecto

Route Groups para layouts distintos sin tocar la URL:

app/
├── layout.tsx              # Root Layout
├── (marketing)/            # Landing, About
│   ├── layout.tsx          # Sin Sidebar
│   └── page.tsx
├── (dashboard)/            # Panel
│   ├── layout.tsx          # Con Sidebar
│   ├── page.tsx
│   ├── users/
│   │   └── page.tsx
│   └── settings/
│       └── page.tsx
└── (auth)/                 # Login, registro
    ├── layout.tsx
    ├── login/
    └── register/

Ventajas: layouts aislados, URLs limpias (/users no /dashboard/users), fácil ampliar.

3.2 Root Layout

// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { SidebarProvider } from "@/components/ui/sidebar";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Admin Dashboard",
  description: "Built with shadcn/ui and Next.js",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="es">
      <body className={inter.className}>
        <SidebarProvider>
          {children}
        </SidebarProvider>
      </body>
    </html>
  );
}

SidebarProvider aquí — no en Dashboard Layout — para que el estado colapsado sobreviva entre /users y /settings.

3.3 Dashboard Layout

// app/(dashboard)/layout.tsx
import { AppSidebar } from "@/components/app-sidebar";
import { SidebarInset, SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <AppSidebar />
      <SidebarInset>
        <header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
          <SidebarTrigger className="-ml-1" />
          <Separator orientation="vertical" className="mr-2 h-4" />
          <Breadcrumb>
            <BreadcrumbList>
              <BreadcrumbItem className="hidden md:block">
                <BreadcrumbLink href="/dashboard">
                  Panel
                </BreadcrumbLink>
              </BreadcrumbItem>
              <BreadcrumbSeparator className="hidden md:block" />
              <BreadcrumbItem>
                <BreadcrumbPage>Resumen</BreadcrumbPage>
              </BreadcrumbItem>
            </BreadcrumbList>
          </Breadcrumb>
        </header>
        <main className="flex-1 p-4 pt-6">{children}</main>
      </SidebarInset>
    </>
  );
}

Incluye AppSidebar, SidebarInset, header con trigger y migas, y main.

3.4 AppSidebar

Configuración centralizada:

// lib/navigation.ts
import {
  Home,
  Users,
  Settings,
  FileText,
  BarChart3,
  Shield,
} from "lucide-react";

export interface NavItem {
  title: string;
  href: string;
  icon: React.ComponentType<{ className?: string }>;
  badge?: string;
}

export const navConfig: NavItem[] = [
  {
    title: "Resumen",
    href: "/dashboard",
    icon: Home,
  },
  {
    title: "Usuarios",
    href: "/users",
    icon: Users,
    badge: "12",
  },
  {
    title: "Analítica",
    href: "/analytics",
    icon: BarChart3,
  },
  {
    title: "Contenido",
    href: "/content",
    icon: FileText,
  },
  {
    title: "Ajustes",
    href: "/settings",
    icon: Settings,
  },
  {
    title: "Permisos",
    href: "/permissions",
    icon: Shield,
  },
];

Componente:

// components/app-sidebar.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar";
import { navConfig } from "@/lib/navigation";
import { Logo } from "@/components/logo";
import { UserNav } from "@/components/user-nav";

export function AppSidebar() {
  const pathname = usePathname();

  return (
    <Sidebar>
      <SidebarHeader className="border-b border-border">
        <Logo />
      </SidebarHeader>

      <SidebarContent>
        <SidebarGroup>
          <SidebarGroupLabel>Menú</SidebarGroupLabel>
          <SidebarGroupContent>
            <SidebarMenu>
              {navConfig.map((item) => {
                const isActive = pathname === item.href;

                return (
                  <SidebarMenuItem key={item.href}>
                    <SidebarMenuButton
                      asChild
                      isActive={isActive}
                      tooltip={item.title}
                    >
                      <Link href={item.href}>
                        <item.icon className="h-4 w-4" />
                        <span>{item.title}</span>
                        {item.badge && (
                          <span className="ml-auto text-xs bg-primary text-primary-foreground rounded-full px-2 py-0.5">
                            {item.badge}
                          </span>
                        )}
                      </Link>
                    </SidebarMenuButton>
                  </SidebarMenuItem>
                );
              })}
            </SidebarMenu>
          </SidebarGroupContent>
        </SidebarGroup>
      </SidebarContent>

      <SidebarFooter className="border-t border-border">
        <UserNav />
      </SidebarFooter>
    </Sidebar>
  );
}

Resaltado: usePathname() + isActive.

3.5 Menú multinivel

import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";

<Collapsible defaultOpen>
  <SidebarMenuItem>
    <CollapsibleTrigger asChild>
      <SidebarMenuButton>
        <Settings className="h-4 w-4" />
        <span>Ajustes</span>
        <ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
      </SidebarMenuButton>
    </CollapsibleTrigger>
    <CollapsibleContent>
      <SidebarMenuSub>
        <SidebarMenuSubItem>
          <SidebarMenuSubButton href="/settings/general">
            <span>General</span>
          </SidebarMenuSubButton>
        </SidebarMenuSubItem>
        <SidebarMenuSubItem>
          <SidebarMenuSubButton href="/settings/security">
            <span>Seguridad</span>
          </SidebarMenuSubButton>
        </SidebarMenuSubItem>
      </SidebarMenuSub>
    </CollapsibleContent>
  </SidebarMenuItem>
</Collapsible>

4. Funciones avanzadas

4.1 RBAC

// lib/navigation.ts
export interface NavItem {
  title: string;
  href: string;
  icon: React.ComponentType<{ className?: string }>;
  roles?: string[];
}

export const navConfig: NavItem[] = [
  {
    title: "Resumen",
    href: "/dashboard",
    icon: Home,
  },
  {
    title: "Usuarios",
    href: "/users",
    icon: Users,
    roles: ["admin", "manager"],
  },
  {
    title: "Permisos",
    href: "/permissions",
    icon: Shield,
    roles: ["admin"],
  },
];

Filtrado en AppSidebar:

import { useAuth } from "@/hooks/use-auth";

export function AppSidebar() {
  const pathname = usePathname();
  const { user } = useAuth();

  const filteredNav = navConfig.filter((item) => {
    if (!item.roles) return true;
    return item.roles.some((role) => user?.roles?.includes(role));
  });

  return (
    <Sidebar>
      {/* ... */}
      <SidebarMenu>
        {filteredNav.map((item) => {
          // ...
        })}
      </SidebarMenu>
      {/* ... */}
    </Sidebar>
  );
}

4.2 Enlaces externos y grupos

<SidebarGroup>
  <SidebarGroupLabel>Funciones principales</SidebarGroupLabel>
  <SidebarGroupContent>
    <SidebarMenu>
      {/* ítems principales */}
    </SidebarMenu>
  </SidebarGroupContent>
</SidebarGroup>

<SidebarGroup>
  <SidebarGroupLabel>Ayuda</SidebarGroupLabel>
  <SidebarGroupContent>
    <SidebarMenu>
      <SidebarMenuItem>
        <SidebarMenuButton asChild>
          <a href="https://docs.example.com" target="_blank" rel="noopener">
            <BookOpen className="h-4 w-4" />
            <span>Documentación</span>
            <ExternalLink className="ml-auto h-3 w-3" />
          </a>
        </SidebarMenuButton>
      </SidebarMenuItem>
      <SidebarMenuItem>
        <SidebarMenuButton asChild>
          <a href="mailto:[email protected]">
            <HelpCircle className="h-4 w-4" />
            <span>Contacto</span>
          </a>
        </SidebarMenuButton>
      </SidebarMenuItem>
    </SidebarMenu>
  </SidebarGroupContent>
</SidebarGroup>

4.3 Búsqueda (Cmd+K)

import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";

<SidebarGroup>
  <SidebarGroupContent>
    <Command className="rounded-lg border shadow-md">
      <CommandInput placeholder="Buscar en el menú..." />
      <CommandList>
        <CommandEmpty>Sin resultados</CommandEmpty>
        <CommandGroup heading="Sugerencias">
          {navConfig.map((item) => (
            <CommandItem key={item.href} onSelect={() => router.push(item.href)}>
              <item.icon className="mr-2 h-4 w-4" />
              {item.title}
            </CommandItem>
          ))}
        </CommandGroup>
      </CommandList>
    </Command>
  </SidebarGroupContent>
</SidebarGroup>

5. Rendimiento y buenas prácticas

Server Components primero

  • AppSidebar como "use client" (usePathname)
  • Header/Footer estáticos como Server Components si puedes
  • navConfig generado en servidor y pasado al cliente

Menos JS en el cliente.

Carga diferida de menús grandes

import dynamic from "next/dynamic";

const AdminMenu = dynamic(() => import("./admin-menu"), {
  loading: () => <SidebarMenuSkeleton />,
});

En la práctica, pocos paneles tienen decenas de ítems.

Accesibilidad

  1. Icono y texto
  2. No ocultes el foco visible
  3. Prueba Tab y flechas

6. Preguntas frecuentes

¿Se pierde el estado del Sidebar al recargar?

Si SidebarProvider está solo en Dashboard Layout, sí. Súbelo a Root Layout.

¿Cerrar Sidebar en móvil?

const { setOpenMobile } = useSidebar();

<SidebarMenuButton
  onClick={() => setOpenMobile(false)}
>

¿Ancho personalizado?

<Sidebar
  style={{
    "--sidebar-width": "280px",
    "--sidebar-width-mobile": "100%",
  }}
>

O cambia SIDEBAR_WIDTH en sidebar.tsx.


Resumen

Sidebar de shadcn/ui + Layout de Next.js acelera el esqueleto del panel:

  1. Entiende Provider, Sidebar, Content, etc.
  2. Route Groups + SidebarProvider en Root Layout
  3. navConfig como única fuente de verdad
  4. usePathname + isActive
  5. roles para RBAC

Lo he reutilizado en varios proyectos: nueva página = una entrada en navConfig.

En el próximo artículo hablaré de DataTable con shadcn/ui.


Referencias

Montar esqueleto de panel con Sidebar de shadcn/ui y Layout de Next.js

Construye desde cero un layout de administración extensible con barra lateral, resaltado de rutas y control de permisos

⏱️ Estimated time: 45 min

  1. 1

    Step 1: Instalar shadcn/ui y añadir Sidebar

    Inicializa el proyecto y añade el componente:

    ```bash
    npx shadcn@latest init
    npx shadcn@latest add sidebar
    ```

    Elige la configuración de estilo por defecto. Tras terminar, tendrás sidebar.tsx en components/ui.
  2. 2

    Step 2: Configurar Root Layout

    Envuelve la app con SidebarProvider en app/layout.tsx:

    • Importa SidebarProvider
    • Envuelve {children} dentro de body
    • Define lang="es"

    Así el estado del Sidebar persiste de forma global.
  3. 3

    Step 3: Crear Dashboard Layout

    Crea app/(dashboard)/layout.tsx:

    • Usa la sintaxis de Route Groups (dashboard)
    • Importa AppSidebar y SidebarInset
    • Añade Header superior y migas de pan

    Los Route Groups no aparecen en la URL: /dashboard sigue siendo la ruta raíz del grupo.
  4. 4

    Step 4: Definir configuración de navegación

    Crea lib/navigation.ts:

    • Interfaz NavItem (title, href, icon, badge)
    • Exporta el array navConfig
    • Opcional: campo roles para permisos

    Añadir una página nueva es una línea en la config.
  5. 5

    Step 5: Implementar AppSidebar

    Crea components/app-sidebar.tsx:

    • Marca "use client"
    • usePathname para la ruta actual
    • Itera navConfig y renderiza ítems
    • isActive cuando la ruta coincide
  6. 6

    Step 6: Añadir control de permisos (opcional)

    Filtrado RBAC:

    • Campo roles en NavItem
    • useAuth para el rol del usuario
    • filter sobre navConfig

    Sin roles, el ítem es visible para todos.

FAQ

¿En qué se diferencia Sidebar de shadcn/ui del de Ant Design?
shadcn/ui copia el código a tu repo: control total. Ant Design es un design system cerrado, rápido de arrancar pero costoso de personalizar. Si necesitas mucha customización, shadcn/ui; si priorizas velocidad, Ant Design.
¿SidebarProvider en Root Layout o en Dashboard Layout?
Mejor en Root Layout (app/layout.tsx). Así el estado colapsado sobrevive entre páginas. En Dashboard Layout se resetea en cada navegación.
¿Cómo cerrar el Sidebar en móvil al pulsar un ítem?
En móvil el Sidebar pasa a modo Sheet. Al hacer clic en un ítem:

```tsx
const { setOpenMobile } = useSidebar();
<SidebarMenuButton onClick={() => setOpenMobile(false)}>
```

El cajón se cierra solo.
¿Cómo personalizar el ancho del Sidebar?
Dos opciones:

1. Variable CSS (recomendado):
```tsx
<Sidebar style={{ "--sidebar-width": "280px" }} />
```

2. Constante SIDEBAR_WIDTH en sidebar.tsx

La variable CSS permite anchos distintos por instancia.
¿Cómo implementar menú multinivel?
Envuelve SidebarMenuItem con Collapsible:

• CollapsibleTrigger para el ítem de primer nivel
• CollapsibleContent con SidebarMenuSub
• Icono ChevronDown para indicar expansión

Código completo en la sección 3.5 del artículo.
¿Qué tal la accesibilidad del Sidebar de shadcn/ui?
Basado en Radix UI: navegación por teclado (Tab/flechas), ARIA y gestión de foco. Asegúrate de combinar icono y texto; no uses solo iconos.

7 min de lectura · Publicado el: 27 mar 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog