Scalable Next.js Project Structure with App Router

Photo by Unsplash

Photo by Unsplash
Every Next.js project starts simple — a handful of pages, some components, maybe a lib folder. Then three months in, you're hunting for a utility function buried somewhere in a component file, and your app directory looks like a digital hoarder's attic. I've rebuilt this structure twice on real projects, and the pattern I'm sharing here is what I actually use at Commsult for commercial web apps.
The App Router introduced a fundamentally different mental model. Files inside app/ can be Server Components, Client Components, layouts, loading states, error boundaries, or API routes — all colocated. Without a deliberate folder strategy, this becomes chaotic fast. The structure I use separates concerns cleanly: routing lives in app/[locale]/(pages)/, shared components in app/components/, utilities in app/lib/, and so on.
Route groups (folders wrapped in parentheses) let you organize routes without affecting the URL. I use (pages) to group all user-facing pages under the locale prefix without adding 'pages' to the URL. This means /en/about resolves from app/[locale]/(pages)/about/page.tsx. You can also use route groups to apply different layouts — for example, a (dashboard) group with a sidebar layout separate from (marketing) pages with a plain navbar.
A common debate: should feature components live next to their routes, or in a centralized components folder? My rule: if a component is only used by one route, colocate it. If it's shared across two or more routes, move it to app/components/. This prevents the 'where does this live?' problem. For a blog, BlogCard and BlogFilters only belong to the blog listing — so they live in app/[locale]/(pages)/blog/. But Navbar and Footer are global, so they're in app/components/layout/.
my-nextjs-app/
├── app/
│ ├── [locale]/
│ │ ├── (pages)/
│ │ │ ├── about/page.tsx
│ │ │ ├── blog/
│ │ │ │ ├── page.tsx # listing
│ │ │ │ └── [slug]/page.tsx # detail
│ │ │ └── contact/page.tsx
│ │ ├── layout.tsx # locale-aware root layout
│ │ └── page.tsx # home
│ ├── api/
│ │ └── [...route]/route.ts # catch-all API handler
│ ├── components/
│ │ ├── ui/ # shadcn/ui or custom primitives
│ │ ├── layout/ # Navbar, Footer, Sidebar
│ │ └── [feature]/ # feature-specific components
│ ├── hooks/ # custom React hooks
│ ├── lib/ # shared utilities, configs
│ ├── types/ # global TypeScript types
│ └── globals.css
├── messages/ # i18n JSON files (next-intl)
│ ├── en.json
│ └── id.json
├── public/
│ └── images/
├── prisma/
│ └── schema.prisma
└── next.config.tsUse barrel exports (index.ts files) in your component folders. Instead of importing from deep paths like @/components/ui/Button, you export everything from @/components/ui/index.ts and import { Button, Input, Modal } from '@/components/ui'. This makes refactoring internal file names painless.
Path aliases are non-negotiable on any project with more than 3 developers or that will live longer than a month. Relative imports like '../../../lib/db' are a maintenance nightmare. Configure tsconfig.json paths once, use @/lib/db everywhere. The singleton Prisma client pattern is also critical — instantiating PrismaClient on every hot module reload in development will exhaust your database connection pool within minutes.
Keep all environment variable parsing in one place: app/lib/env.ts. Use the t3-env or zod-based pattern to validate env vars at startup, not at runtime when a user triggers a code path. This catches missing STRIPE_SECRET_KEY or DATABASE_URL before you deploy, not in production at 2am. Export typed constants from this file so every consumer gets proper autocomplete and no raw process.env access scattered through the codebase.
// tsconfig.json — path aliases
{
"compilerOptions": {
"paths": {
"@/*": ["./*"],
"@/components/*": ["./app/components/*"],
"@/lib/*": ["./app/lib/*"],
"@/types/*": ["./app/types/*"],
"@/hooks/*": ["./app/hooks/*"]
}
}
}
// app/components/ui/index.ts — barrel export
export { Button } from "./Button"
export { Input } from "./Input"
export { Modal } from "./Modal"
export { Badge } from "./Badge"
// Usage anywhere in the project
import { Button, Input, Modal } from "@/components/ui"
// app/lib/db.ts — singleton Prisma client
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query"] : [],
})
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prismaLayer-based (controllers/, services/, repositories/) works well for backend NestJS apps. For a Next.js frontend, I prefer feature-based grouping inside components/. A 'products' feature folder contains ProductCard.tsx, ProductFilters.tsx, ProductTable.tsx, and useProducts.ts. This makes it easy to delete or move an entire feature without hunting through multiple directories.
Nesting route groups more than two levels deep creates Next.js layout inheritance chains that are hard to reason about. If you find yourself at app/[locale]/(pages)/(dashboard)/(settings)/profile/page.tsx, consider flattening. The route group is for layout sharing, not for expressing hierarchy that isn't in the URL.
The [locale] dynamic segment at the root gives you internationalization without a separate i18n directory sprawl. All translations live in messages/en.json and messages/id.json at the project root. The next-intl library handles the rest — useTranslations() in Client Components, getTranslations() in Server Components. Keep translation key namespaces consistent with your feature structure: blog.posts.*, dashboard.sidebar.*, common.* for shared labels.
Before a project grows beyond MVP, run through this: (1) Are all shared utilities in lib/, not scattered in components? (2) Do all Server Components stay server-side — no useState or useEffect imports? (3) Is Prisma instantiated as a singleton? (4) Do API routes use proper error handling with consistent response shapes? (5) Are environment variables validated at startup? (6) Is there a single tsconfig paths config that the whole team uses? Structure is cheapest to fix before you have 50 files depending on the wrong pattern.