This is the second part of a two-part series on front-end architecture and Next.js folder structure. In Part 1 we covered the core principles, the App Router special files, and the concrete folder layouts for small, medium, and large projects — ending with a feature-based structure for large, multi-team applications.
Defining that structure is only half the job. A large folder tree that nobody enforces quietly rots: features start importing each other's internals, "global" state ends up scattered, and naming drifts until nothing is predictable anymore. This article picks up exactly where Part 1 left off and focuses on the practices that keep a large Next.js codebase healthy over time.
If you haven't read it yet, start with Part 1: Next.js Folder Structure for Small, Medium, and Large Projects. The examples below assume the feature-based structure introduced there.
Give Each Feature a Public API
A feature folder without an explicit public API is just a namespace — nothing stops another feature from reaching deep into its internals and coupling to files that were meant to be private. The fix is a barrel index.ts that acts as the feature's front door:
Now the rest of the app imports from '@/features/billing' and only sees what you chose to expose. You are free to refactor everything behind that barrel without breaking a single consumer. This is the same encapsulation idea a well-designed npm package uses, applied inside your own repo.
One caveat worth knowing: large barrel files can hurt tree-shaking and create accidental circular dependencies if overused. Keep barrels at the feature boundary (one per feature), not on every subfolder, and never import a feature's barrel from inside that same feature.
Path Aliases and Enforced Boundaries
At large scale, relative imports like ../../../shared/ui/Button become unreadable and break every time you move a file. Configure TypeScript path aliases once in tsconfig.json:
But aliases alone are just convenience. The real win is turning your architecture into rules a linter can enforce, so boundaries survive contact with a deadline. With ESLint's no-restricted-imports (or a tool like dependency-cruiser), you can make illegal dependencies fail CI:
Rules like "features may not import each other directly," "nothing may import from app/," and "domain/ may not import React" are what keep a large codebase from quietly degrading into a big ball of mud. Structure that isn't enforced is just a suggestion.
State Management and Providers
Global state is a classic source of folder-structure confusion. A few guidelines that scale well:
- Server state (data from your backend) is not "global state." Handle it with the App Router's caching and, on the client, a library like TanStack Query or SWR. Don't dump API data into a global store.
- Genuinely global client state (theme, current user, feature flags) belongs in providers. Colocate the provider with its logic (
shared/providers/ThemeProvider.tsx) and compose them in a singleapp/providers.tsxClient Component that the rootlayout.tsxrenders. - Feature-local state stays inside the feature. A Zustand store used only by billing lives at
features/billing/store.ts, not in a top-levelstore/.
This keeps the root layout.tsx a Server Component while isolating all client-side context in one predictable place.
Monorepo Considerations
For very large products, a single Next.js app eventually splits into multiple apps (web, admin, marketing site) that share design system, types, and clients. At that point, a monorepo (pnpm workspaces with Turborepo or Nx) is often the next step:
You don't need a monorepo on day one — but if you find yourself copy-pasting components or types between projects, it's time.
Naming Conventions and the src/ Directory
Consistency matters more than the specific convention you pick — but pick one and encode it in a lint rule. A widely used, low-friction set of conventions:
| Kind | Convention | Example |
|---|---|---|
| React components | PascalCase | InvoiceTable.tsx |
| Hooks | camelCase, use prefix | useInvoices.ts |
| Utilities / non-component TS | camelCase | formatAmount.ts |
| Route segment folders | kebab-case | app/reset-password/ |
| Types / interfaces | PascalCase | type Invoice = { ... } |
| Constants | UPPER_SNAKE_CASE | MAX_RETRY_COUNT |
Two more practical decisions:
- Use the
src/directory for anything past the small scale. Movingapp/,features/, andshared/undersrc/cleanly separates application code from config files (next.config.js,tailwind.config.js,package.json) that clutter the root. Next.js supports this out of the box. - Prefer named exports over default exports for components and utilities. Named exports make renames safe, autocomplete reliable, and barrel files trivial. The one exception is where Next.js requires a default export:
page.tsx,layout.tsx, and the other special files.
Common Pitfalls
Regardless of size, the same mistakes show up again and again:
- God folders:
utils/,helpers/, orcommon/that become a dumping ground. Break them up by purpose (format/,dom/,string/) or move things into the feature that owns them. - Deep nesting for no reason:
components/feature/section/sub/widget/inner/. If a path is five levels deep, the folders are doing the wrong job. - Mixing server and client logic: importing a database client from a Client Component, or React hooks from a server utility. Make the boundary loud.
- Circular dependencies between features: feature A imports from feature B, which imports from feature A. Usually a sign that the shared part should be lifted into
shared/or a new feature. - Inconsistent naming:
userProfile.tsx,UserCard.tsx,user-settings.tsxin the same folder. Pick one convention (PascalCase for components is the most common in React) and enforce it with lint rules. - Premature abstraction: a
features/folder with a singlehomefeature is just noise. Wait until structure earns its place.
A Simple Decision Checklist
Use this when you're deciding which structure your project needs today:
- Small — pick this if: one or two developers, fewer than ~20 routes, no real domain logic, lifetime measured in months.
- Medium — pick this if: a small team, multiple sections (marketing + auth + product), real forms and APIs, lifetime measured in years.
- Large — pick this if: multiple teams, multiple bounded contexts, more than a handful of long-lived business domains, or you already feel the pain of cross-cutting changes.
When in doubt, choose one size smaller than you think. It's much easier to promote a folder than to delete one.
The Three Scales at a Glance
| Aspect | Small | Medium | Large |
|---|---|---|---|
| Top-level grouping | By type | By type, with route groups | By feature (features/) |
src/ directory | Optional | Recommended | Yes |
| Components | Flat components/ | ui/ + shared/ | shared/ui/ + per-feature components/ |
| Data / logic layer | lib/ | lib/ split by responsibility | Per-feature api/ + domain/ + infrastructure/ |
| Boundaries enforced | Not needed | Path aliases | Path aliases + lint rules + feature public APIs |
| State | Local / URL | Providers + server state library | Layered: server, global, and feature-local |
| Team size | 1–2 | Small single team | Multiple teams |
Migrating Between Structures
You rarely rebuild from scratch — you evolve. The good news is that each step is additive, not a rewrite:
- Small to medium: introduce
src/, add route groups toapp/, splitcomponents/intoui/andshared/, and break the singlelib/into focused subfolders. No feature code has to move yet. - Medium to large: carve out
features/one domain at a time. Move a feature's components, hooks, and API calls intofeatures/<name>/, add anindex.tspublic API, update imports, then add a lint rule to lock the boundary. Migrate the noisiest, most-changed feature first — it delivers the most relief.
Do it incrementally, one feature per pull request, so the codebase is shippable at every step.
Conclusion
Folder structure in Next.js is not about following a template — it's about matching the shape of your code to the shape of your product. In Part 1 we started with a flat layout for small projects, added route groups and a richer lib/ for medium ones, and moved to a feature-based, layered architecture for large ones. In this part we saw what it takes to keep that large structure from decaying: explicit feature APIs, enforced import boundaries, deliberate state placement, and consistent conventions.
The same rules apply at every size: colocate what changes together, separate what changes independently, and never let structure get ahead of the actual code. A good folder structure should feel almost invisible — you open the project, and the next file you need to edit is exactly where you expected it to be.