Every Next.js 15 project starts clean. Six months later, half of them are a mess of components dumped in /app, utils folders no one understands, and server/client logic mixed randomly. Here's the structure we use after building 50+ production Next.js apps.
The Core Problem with App Router Projects
The App Router's file-system routing is powerful but opinionated in exactly one way: how routes map to files. Everything else — component organization, data fetching patterns, shared logic, server vs client boundaries — is up to you.
Most teams discover their mistakes at scale, not during setup. This post skips the discovery phase.
The Structure
my-app/
├── app/ # Routes only — no logic here
│ ├── (marketing)/ # Route group: public pages
│ │ ├── page.tsx
│ │ └── about/page.tsx
│ ├── (dashboard)/ # Route group: authenticated app
│ │ ├── layout.tsx # Auth check here
│ │ ├── dashboard/page.tsx
│ │ └── settings/page.tsx
│ ├── api/ # API routes
│ │ └── [...]/route.ts
│ ├── layout.tsx # Root layout
│ └── globals.css
│
├── components/ # Shared UI components
│ ├── ui/ # Primitives (Button, Input, etc.)
│ ├── forms/ # Form components
│ └── layouts/ # Page layout shells
│
├── features/ # Feature modules (the key pattern)
│ ├── auth/
│ │ ├── components/ # Auth-specific UI
│ │ ├── hooks/ # useAuth, useSession
│ │ ├── actions.ts # Server actions for auth
│ │ └── types.ts
│ ├── billing/
│ │ ├── components/
│ │ ├── actions.ts
│ │ └── types.ts
│ └── dashboard/
│ ├── components/
│ ├── hooks/
│ └── actions.ts
│
├── lib/ # Infrastructure / integrations
│ ├── db/ # Prisma client + queries
│ │ ├── client.ts
│ │ └── queries/
│ ├── auth/ # Clerk/NextAuth config
│ ├── stripe/ # Stripe client + webhooks
│ └── email/ # Resend/email templates
│
├── hooks/ # Global client-side hooks
├── types/ # Global TypeScript types
├── utils/ # Pure utility functions
└── config/ # App configuration
├── site.ts # Site metadata
└── nav.ts # Navigation config
The Rules Behind the Structure
1. App directory = routes only
No business logic in app/. No data fetching in page components beyond calling a function from features/ or lib/. The page file should be readable in 30 seconds.
// app/(dashboard)/dashboard/page.tsx — good
import { getDashboardData } from '@/features/dashboard/actions'
import { DashboardView } from '@/features/dashboard/components/DashboardView'
export default async function DashboardPage() {
const data = await getDashboardData()
return <DashboardView data={data} />
}
2. Features over shared components
The mistake: putting everything in /components. You end up with a 40-file flat list where UserCard.tsx is next to PricingTable.tsx and nobody knows what's shared vs feature-specific.
The fix: feature modules. Auth-related UI lives in features/auth/components/. It can only be imported by auth routes and the root layout. Dashboard components live in features/dashboard/. If something is used in two features, it graduates to /components.
3. Server actions are the API layer
For most Next.js apps, you don't need separate API routes for your own data. Server actions in features/*/actions.ts replace the traditional API route pattern for form submissions, mutations, and authenticated data fetching.
// features/billing/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { stripe } from '@/lib/stripe'
export async function createCheckoutSession(priceId: string) {
const { userId } = await auth()
// ... create session
}
Keep API routes (app/api/) for: webhooks (Stripe, Clerk), public endpoints (other services consuming your API), and file upload handlers.
4. Explicit server/client split
Every component is a Server Component by default. Add 'use client' only when you need:
-
useState/useEffect - Browser APIs
- Event handlers
- Context consumers
The boundary: pass data down from Server Components as props. Keep interactive islands small.
// features/dashboard/components/MetricsCard.tsx — Server Component (no directive)
export function MetricsCard({ value, label }: Props) {
return <div>{label}: {value}</div> // Static, no interactivity needed
}
// features/dashboard/components/MetricsChart.tsx — Client Component (needs recharts)
'use client'
import { LineChart } from 'recharts'
export function MetricsChart({ data }: Props) {
return <LineChart data={data} />
}
5. Route groups for auth boundaries
Use route groups (marketing) and (dashboard) to separate public and authenticated routes. Put auth checking in the group layout, not on individual pages.
// app/(dashboard)/layout.tsx
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
export default async function DashboardLayout({ children }) {
const session = await auth()
if (!session) redirect('/login')
return <>{children}</>
}
What Goes Where: Quick Reference
| Code | Goes in |
|---|---|
| Page-level UI | app/*/page.tsx |
| Shared UI primitives | components/ui/ |
| Feature-specific UI | features/[name]/components/ |
| Server mutations | features/[name]/actions.ts |
| Third-party clients | lib/[service]/ |
| Database queries | lib/db/queries/ |
| Client-side state hooks |
features/[name]/hooks/ or hooks/
|
| TypeScript types |
features/[name]/types.ts or types/
|
| Config/constants | config/ |
The Scaling Test
Ask these questions about any file you create:
- Could a new team member find this without asking? If not, it's in the wrong place.
- If this feature is deleted, can I delete one folder and be done? If not, it's too coupled.
- Is this a Server or Client Component? If you're unsure, it should be a Server Component until you need client features.
Starter Template
We maintain a Next.js 15 full-stack project structure with this layout pre-wired — includes Prisma, Clerk auth, Stripe, Resend, and shadcn/ui. Clone and go.
What's tripping you up in your Next.js structure? Happy to answer specific questions.
Top comments (0)