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)supplieslayout.tsx, and with it the role check and the shell.adminis 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.tsxper URL. Two groups both definingpage.tsxfor/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*. LayoutPropsandPagePropsare generated per URL route. A group's layout has the same route literal as the layout above it, which is why the(admin)layout typeschildrenby hand instead of usingLayoutProps<"/">.- Loading and error boundaries follow the folder, not the URL.
(admin)/loading.tsxcovers 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.