Skip to content

Route group or path segment: how to lay out an admin section

A route group shares a layout without touching the URL; a path segment is the URL. Admin panels need both, and confusing them produces public pages and 404s.

Admin panel4 min readships at docs/solutions/admin-panel/route-group-vs-path-segment.md

Tags: nextjs · app-router · routing · layouts · admin

src/app/(admin)/admin/users/page.tsx looks redundant the first time you see it. Why is "admin" in the path twice? Deleting either copy breaks something, and which thing breaks depends on which copy you delete, so it is worth understanding what each one does.

The two mechanisms

A path segment is a folder whose name appears in the URL. src/app/admin/users/page.tsx serves /admin/users.

A route group is a folder whose name is in parentheses. It is invisible to the URL and exists to give a set of routes a shared layout, or to keep them out of another layout. src/app/(admin)/dashboard/page.tsx serves /dashboard, with the layout from (admin).

So in src/app/(admin)/admin/users/page.tsx:

  • (admin) supplies layout.tsx, and with it the role check and the shell.
  • admin is the first URL segment.
  • The URL is /admin/users.

What each mistake produces

Delete the group; keep the segment. src/app/admin/users/page.tsx serves the same URL and inherits only the root layout. The layout's role check and the sidebar are gone. Unless the page checks the role itself, it is public and nothing warns you. This is the dangerous one, and the reason every admin page should call the guard too.

Delete the segment; keep the group. src/app/(admin)/users/page.tsx serves /users, not /admin/users. Every link 404s, and the page you meant to hide behind /admin is now at a top-level URL that a proxy matcher on /admin/* does not cover.

Use only a segment and put the layout in it. src/app/admin/layout.tsx plus src/app/admin/users/page.tsx works perfectly and is a legitimate design. The reason to prefer the group is that it can hold routes that are not under /admin (a /impersonate route, a /support console) while still sharing the layout and the check. If you are certain everything will live under one prefix, the plain segment is simpler.

Why the group is the better default here

The layout is where the boundary lives. A group makes it obvious that the grouping is the boundary: everything inside (admin) is admin. A page moved out of the folder loses protection, and the folder name is the reminder.

Route groups can be added without breaking URLs. Wrapping an existing admin/ folder in (admin)/ changes no URL and no link. Adding a segment changes every URL.

Two groups can share a URL space. (marketing)/page.tsx and (app)/dashboard/page.tsx can have completely different chrome without a URL prefix distinguishing them.

Rules that avoid the traps

  • One page.tsx per URL. Two groups both defining page.tsx for / is a build error, and the message names the conflict but not which one you meant to keep.
  • A group is not a segment in usePathname(). Highlighting the active nav item compares against /admin/users, never /(admin)/admin/users.
  • A group is not a segment in a proxy matcher either. Match /admin/:path*.
  • LayoutProps and PageProps are generated per URL route. A group's layout has the same route literal as the layout above it, which is why the (admin) layout types children by hand instead of using LayoutProps<"/">.
  • Loading and error boundaries follow the folder, not the URL. (admin)/loading.tsx covers everything in the group, which is usually what you want for an admin panel: one skeleton, one error page.

A layout that works for the whole section

// src/app/(admin)/layout.tsx
export default async function AdminLayout({ children }: { children: ReactNode }) {
  const admin = await requireRole("admin", "/admin");
  return <AdminShell user={toShellUser(admin)}>{children}</AdminShell>;
}

The layout keeps non-admins out of the shell. It does not re-run on client navigation, so each page in the group calls the same requireRole again with its own path; with the session read wrapped in React cache, that costs nothing.

Add error.tsx and not-found.tsx inside the group (next to the layout, or in (admin)/admin/). Without them, an admin page that throws renders the app-wide error page and the operator loses the navigation they were using, a small thing that is very annoying at 2am.

When to add a second group

The signal is a page that needs a different shell: a full-screen impersonation banner, a print view of an invoice, an embedded report with no chrome. Rather than adding conditionals to AdminShell, add (admin-bare) with its own minimal layout, and give it the same role check, because the group is the boundary and a new group is a new boundary that starts empty.

Checking your work

  • Every file under (admin) renders inside the shell, and every URL it serves starts with /admin.
  • Deleting the group folder name from a path is a change you would catch in review, because you know what it does now.
  • usePathname() never contains parentheses.
  • Signed out, every URL in the section redirects to sign-in.