App Shell layouts

One component, nine arrangements. Every one below is the same AppShell from the published package with different props — pick one and the frame rebuilds in place. The props themselves are documented at App Shell.

Live comparison

The first four are about how the frame itself is arranged; the last five are about an app split into modules. Click around inside the frame — the menu really moves, the open module really changes. Keyboard shortcuts are off and nothing is written to your localStorage. On a phone the frame shows what every layout becomes below md: open ☰.

Frame
Modules (3.16.0)
Skip to main content

Sidebar first

layout="sidebar-first"

The default. The sidebar runs the full height with the workspace switcher at its top; the header sits beside it with search, launcher, bell and user menu.

pathname
/tasks/inbox
Fits when
Most products: a menu of five to fifteen screens, used all day.
Sidebar first — tsx
<AppShell
  layout="sidebar-first" // the default — you can leave it out
  workspaces={WORKSPACES}
  currentWorkspaceId={workspaceId}
  apps={APPS}
  nav={NAV}
  user={user}
>
  {children}
</AppShell>

At a glance

LayoutPropsFits whenBelow md
Sidebar firstlayout="sidebar-first"Most products: a menu of five to fifteen screens, used all day.Sidebar moves into the off-canvas menu (☰).
Header firstlayout="header-first"Products where the header carries more than the menu — wide tools, editors, a product with tabs.Same as sidebar-first: header, then the off-canvas menu.
No sidebarshowSidebar={false}A single screen: an inbox, a report viewer, a settings-only console.Unchanged — narrow screens use the off-canvas menu anyway.
Brand onlybrand={…} (3.15.0)An ordinary app outside the workspace × app model.Logo and name top the off-canvas menu.
Modules · groupedmodules={…} moduleLayout="grouped" (3.16.0)A few modules with few screens each — the closest thing to a frame with no modules.As the sidebar, in the off-canvas menu.
Modules · railmoduleLayout="rail" (3.16.0)People who work across several modules, each with a dozen screens.A row of module chips at the top of the off-canvas menu.
Modules · rail + brandmoduleLayout="rail" railHeader="brand" (3.17.0)Any rail frame that should still say which app this is. railBrandCollapsed picks what the folded strip keeps.Same chips as rail; the brand tops the off-canvas menu.
Modules · switchermoduleLayout="switcher" (3.16.0)People who rarely change module — most of a day spent in one.A row of module chips at the top of the off-canvas menu.
Modules · tabsmoduleLayout="tabs" layout="header-first" (3.16.0)Reads most naturally with header-first, and with a handful of modules whose names are short.A row of module chips at the top of the off-canvas menu.

layout and moduleLayout are independent: all four module layouts run in both sidebar-first and header-first. In header-first the rail drops its logo and collapse button, and railHeader has no effect — the logo is already in the header.

Modules from one navigation table

Keep one table, grouped by module, and split it in the client file into the shared nav and the modules. Filter by permission before passing either — the shell infers nothing about rights.

navigation.ts + shell-frame.tsx
// lib/catalog/navigation.ts — ONE table. Group "main" is the shared part; every
// other group is one module, and its id is the module's key.
export const NAV_GROUPS = [
  { id: 'main', items: [OVERVIEW, SETTINGS] },
  { id: 'production', icon: Factory, items: [WORK_ORDERS, SCHEDULE, LINES] },
  { id: 'hr', icon: Users, items: [EMPLOYEES, LEAVE, PAYROLL] },
] as const

// app/(shell)/shell-frame.tsx ('use client') — AppShell lives in the LAYOUT of the
// route group, not in each page: the provider is what remembers the module just left.
const { nav, modules } = useMemo(() => {
  const groups = visibleNavGroups(NAV_GROUPS, permissions) // filter by permission FIRST
  return {
    nav: groups.filter((g) => g.id === 'main').flatMap((g) => g.items.map(toItem)),
    modules: groups.filter(isModuleGroup).map((g): ShellModule => ({
      id: g.id,
      label: tModules(g.id),
      icon: g.icon,
      nav: g.items.map(toItem),
    })),
  }
}, [permissions, tModules])

// Changing the layout later is one line — the table does not move.
<AppShell nav={nav} modules={modules} moduleLayout="rail" railHeader="brand" …>

Rules the module layouts follow

Fewer than two modules is no modules

Every module layout falls back to a plain sidebar; one module becomes one labelled group after the shared items. A module with nothing openable is dropped. So a user with fewer permissions sees a simpler frame on their own — do not branch moduleLayout on role.

The open module is derived, not state

The first module (in the order passed) holding an active item is the open one. On a shared page it is null — no tab, no tile lit — and the sidebar keeps the menu of the module just left. Declare each route in ONE module.

useShell().nav is still every item

The sidebar draws sidebarNav; nav keeps one labelled group per module, so a ⌘K palette reading nav finds every screen whichever module is on display. Keep item ids unique across the whole app.

The rail draws only top-level shared items

A 72px tile has no room for a branch. A shared item with children goes to its own href; put pages with sub-items inside a module.

No colour per module

The accent follows the APP. A module is told apart by its icon and its name — do not give each module a tone of its own.

Modules are not apps

A module is an internal link of the open app; an app is another product on another domain, in the launcher. Do not put internal areas into apps.

Related

@comitor/ui/shellAppShell · ModuleRail · ModuleSwitcher · ModuleTabs · NavBadge · moduleLandingHref