Skip to main content

A (public) Route Group So Admin Pages Render Bare

A clean architectural doorway dividing two rooms
Apr 15, 20263 min readNext.js, React, TypeScript, Testing, Tailwind CSS

The problem

The portfolio has two audiences now. Everything under / is for visitors, so it gets the Nav, the Footer, the mobile bottom-nav, the lot. Everything under /tools/admin/* is for me: a sidebar, a table, and no reason to ship the marketing chrome along with it. Both trees share one root layout.

The old shape rendered Nav and Footer inside AppContext, which wrapped every page, so the admin routes inherited all of it. None of my options looked great on paper:

  • Gate the chrome with usePathname() checks. Fragile, and every admin page still pays the JavaScript cost.
  • Render a conditional server component off the URL. Same cost, just with more branching.
  • Duplicate the root layout for admin. Now there are two trees to maintain and two places to forget a provider.

The approach

App Router route groups are the right tool here. A folder named (public) counts as a path segment at the filesystem level but contributes nothing to the URL, so I can move every public route into app/(public)/ and lift Nav and Footer out of the root layout into a (public)/layout.tsx that only that subtree ever sees.

Admin pages stay outside the group; they walk up to the root layout, which now just composes providers and global UI. No Nav, no Footer, and no conditional rendering anywhere.

The layout

apps/root/src/app/(public)/layout.tsx
import type { ReactNode } from 'react';
import Footer from '@/components/Footer';
import Nav from '@/components/Nav';
 
export default function PublicLayout({ children }: { children: ReactNode }) {
  return (
    <>
      <Nav />
      {children}
      {/* pb-16 compensates for the fixed mobile bottom nav bar */}
      <div className='pb-16 md:pb-0'>
        <Footer />
      </div>
    </>
  );
}

The tree now looks like this:

app/
├── layout.tsx            // html, body, providers, Scripts, skip link
├── (public)/
│   ├── layout.tsx        // Nav + children + Footer
│   ├── page.tsx          // home
│   ├── about/
│   ├── blog/
│   ├── projects/
│   └── ...
├── tools/
│   ├── login/
│   └── admin/            // Sidebar layout, no Nav, no Footer

The URLs don't change, because route groups don't contribute path segments. /about still resolves to (public)/about/page.tsx, and /tools/admin/jobs still resolves to tools/admin/jobs/page.tsx. One tree renders with marketing chrome, the other renders with none, and the only thing that actually moved is where the files live.

A few knock-on cleanups fell out of the split:

  • The admin layout had been using min-h-[calc(100vh-4rem)] to make room for a 4rem global Nav. With the Nav gone, min-h-screen is the right value.
  • AppContext used to import Nav alongside the providers; now it just composes providers, Modal, ScrollToTop, and KeyboardShortcuts, so it finally does the one thing its name promised all along.
  • Two absolute @/app/* imports that crossed into moved folders got rewritten as relative paths so the next move doesn't break them.

The 404 problem

The split broke one thing I didn't see coming: the 404 page.

I'd had app/(public)/not-found.tsx handling unmatched routes, and after the split it quietly stopped firing for URLs that didn't match any file. The reason is documented but easy to skim past: App Router only uses app/not-found.tsx for globally unmatched routes, and a not-found.tsx inside a route group only scopes to that group's matched routes, not to the arbitrary URLs the router can't resolve at all.

So unmatched URLs rendered the stripped root layout with no Nav and no Footer, and an e2e test that asserts a Back to Home link inside <main> started failing on me.

The fix is to move the 404 up to the root and inline the chrome the route group would have handed it:

apps/root/src/app/not-found.tsx
import Nav from '@/components/Nav';
import Footer from '@/components/Footer';
 
export default function NotFound() {
  return (
    <>
      <Nav />
      <main>{/* 404 content */}</main>
      <Footer />
    </>
  );
}

Inlining feels like a step backwards, but it isn't. The root not-found.tsx is the only file App Router reaches for unmatched URLs, so it has to carry its own chrome; no layout above it renders any.

The takeaway

Route groups get pitched as a filesystem-tidiness trick, but the real payoff is conditional layout composition. One subtree gets the marketing chrome, another gets an admin shell, and neither tree needs a usePathname() branch to sort itself out.

The caveat worth keeping in your back pocket: app/not-found.tsx is special. It handles every unmatched route, it ignores groups, and it has to render whatever chrome you want on a 404 page itself.