Next.js App Router Project Structure That Scales
Every Next.js project starts clean. Ten pages in, it's fine. Fifty pages in, developers are searching for files instead of writing code. The structure you choose in week one determines whether your codebase stays navigable or becomes a maze.
Route Groups: Separate Concerns Without Changing URLs
The App Router's parenthesized folders are the most underused feature. They let you organize routes and apply different layouts without affecting the URL:
src/app/
├── (marketing)/ ← navbar + footer layout
│ ├── page.tsx ← /
│ ├── blog/ ← /blog
│ ├── docs/ ← /docs
│ └── components/ ← /components
├── (dashboard)/ ← sidebar + header layout
│ ├── dashboard/ ← /dashboard
│ └── settings/ ← /settings
├── (auth)/ ← centered card layout
│ ├── login/ ← /login
│ └── signup/ ← /signup
└── layout.tsx ← root: providers, fonts, analytics
Each group has its own layout.tsx. Marketing pages get a navbar and footer. Dashboard pages get a sidebar. Auth pages get a minimal centered layout. The root layout wraps everything with providers.
The URL for /dashboard is just /dashboard — the (dashboard) prefix is invisible. This is purely an organizational tool.
Server Components by Default
Every file in the App Router is a server component unless you opt out with "use client". This is the correct default — server components fetch data without client-side loading states, ship zero JavaScript, and render faster.
Add "use client" only when you need browser APIs, React state, or event handlers. The question isn't "should this be a client component?" — it's "does this component need to run in the browser?"
Push the Client Boundary Down
The most common mistake: making an entire page a client component because one part needs interactivity.
// ❌ Entire page ships as client JS
"use client"
export default function SettingsPage() {
const [tab, setTab] = useState("profile")
const user = useQuery(...)
// ...
}
// ✅ Page is server, only the form is client
export default async function SettingsPage() {
const user = await getUser()
return (
<div>
<h1>Settings</h1>
<ProfileForm initialValues={user} /> {/* this is "use client" */}
</div>
)
}
The heading, layout, and data fetch all happen on the server. Only the interactive form ships JavaScript to the browser.
Never "use client" on Layouts
Layouts are shared across all child routes. Making a layout a client component means every page under it ships that JavaScript.
If your layout needs interactive state (like a collapsible sidebar), extract it into a client shell:
// layout.tsx — stays server component
export default function DashboardLayout({ children }) {
return <DashboardShell>{children}</DashboardShell>
}
// dashboard-shell.tsx — "use client"
export function DashboardShell({ children }) {
const [collapsed, setCollapsed] = useState(false)
return <Sidebar collapsed={collapsed}>{children}</Sidebar>
}
Component Organization: Domain, Not Type
Organize components by what they're for, not what they are:
src/components/
├── ui/ ← primitives (button, input, dialog)
├── dashboard/ ← dashboard shell, sidebar, charts, stats
├── auth/ ← login form, signup form, password reset
├── settings/ ← profile, billing, API keys
├── marketing/ ← hero, pricing, testimonials, CTA
├── feedback/ ← toast, alert, error boundary
└── shared/ ← logo, theme toggle, page transition
This means everything related to "dashboard" lives together — the sidebar component, the stats card, the chart wrapper. Not scattered across components/layouts/, components/widgets/, and components/data-viz/.
When a new developer asks "where's the dashboard sidebar?", the answer is always components/dashboard/.
The lib/ Layer
Business logic lives separately from UI:
src/lib/
├── schemas.ts ← Zod schemas (shared client + server)
├── actions.ts ← server actions
├── auth.ts ← session helpers, plan checks
├── utils.ts ← cn(), formatDate(), shared utilities
└── api.ts ← data fetching layer
The dependency rule: components import from lib/. lib/ never imports from components/. This keeps the graph clean and makes testing easier — you can test business logic without rendering UI.
Metadata on Every Page
This is non-negotiable for SEO and social sharing:
export const metadata: Metadata = {
title: "Settings",
description: "Manage your account settings and preferences.",
alternates: { canonical: "/settings" },
}
Set metadataBase and a title template in the root layout. Individual pages only need to set title and description — the template handles branding.
The Navigability Test
A well-structured project passes four tests:
- Find — a new developer can locate any file in under 10 seconds
- Place — a new feature has an obvious directory to live in
- Create — adding a new page requires creating one file, maybe two
- Delete — removing a feature doesn't break unrelated code
If adding a feature requires touching files in five directories, the structure has a problem. If deleting a feature leaves orphaned imports across the codebase, the boundaries are wrong.
Good structure makes the right thing easy and the wrong thing obvious. You shouldn't need a wiki to know where to put a new component.