đ Executive Summary
TL;DR: Large Next.js applications often suffer from features bleeding into one another due to a lack of enforced boundaries, leading to unpredictable side effects. This article outlines three strategiesâESLint-enforced path restrictions, true feature colocation, and monoreposâto achieve robust feature-driven folder isolation, making codebases predictable and safe.
đŻ Key Takeaways
- The default Next.js structure, while easy to start, can lead to a âsoupâ of components and logic in large apps, causing unpredictable ripple effects.
- Path aliases in
tsconfig.jsoncombined witheslint-plugin-importandno-restricted-pathsrules can act as a âquick fixâ to prevent cross-feature imports and enforce boundaries in legacy codebases. - True feature colocation organizes the application by feature, making each a self-contained vertical slice with its own components, hooks, and an
index.tsfile serving as a public API. - Monorepos, managed by tools like Turborepo or Nx, offer the highest level of isolation by treating features as independent packages, suitable for extremely large applications with multiple teams and deployment cadences.
- For most large projects, feature colocation (Solution 2) provides 90% of the benefits of a monorepo with significantly less complexity and tooling overhead.
Struggling with a sprawling Next.js app where features bleed into one another? Here are three battle-tested strategies, from quick fixes with ESLint to full-blown monorepos, for achieving true feature-driven folder isolation.
Taming the Monolith: Real Talk on Feature-Driven Folders in Large Next.js Apps
I still remember the 3 AM PagerDuty alert. The entire checkout flow was down on prod-web-cluster-01. After a frantic half hour of digging through logs, we found the culprit. A junior dev, working on a simple A/B test for the marketing landing page, had updated a âgenericâ Button component in the shared /components folder. This button was, unbeknownst to him, also used for the âConfirm Purchaseâ action, and his âminorâ CSS change broke its onClick handler in Safari. A marketing change took down our revenue stream. That was the day I said, âNever again.â We had to get serious about isolation.
The âWhyâ: How We Get Into This Mess
Letâs be honest, itâs not entirely our fault. The default Next.js structure encourages a centralized approach. You get a /pages (or /app), a /components, a /lib, and you start building. Itâs fast and easy. But as the app grows from 10 pages to 100, that shared /components folder becomes a minefield. You have Card.tsx, CardV2.tsx, and NewCardFinal.tsx. A change in one place has unpredictable ripple effects. The root cause is a lack of enforced boundaries. We create a âsoupâ of components and logic instead of discrete, self-contained features.
Solution 1: The Quick Fix â Path Aliases & ESLint Cops
This is the âstop the bleedingâ approach. It doesnât refactor your whole app, but it puts guardrails in place to prevent the problem from getting worse. It relies on a combination of path mapping for clarity and linting rules to enforce boundaries during development and in your CI/CD pipeline.
First, we define clear paths in tsconfig.json (or jsconfig.json). This makes imports intentional.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/components/*": ["src/components/*"],
"@/features/auth/*": ["src/features/auth/*"],
"@/features/billing/*": ["src/features/billing/*"],
"@/features/dashboard/*": ["src/features/dashboard/*"]
}
}
}
Next, we bring in the sheriff. We use eslint-plugin-import to prevent features from reaching into each otherâs private folders. In your .eslintrc.js file, you add rules that say, âThe billing feature can import from shared, but it absolutely cannot import directly from dashboard.â
{
"rules": {
"import/no-restricted-paths": [
"error",
{
"zones": [
{
"target": "./src/features/billing",
"from": "./src/features/dashboard",
"message": "Hey! The billing feature should not depend on the dashboard. Extract shared logic to a common module if needed."
},
{
"target": "./src/features/auth",
"from": "./src/features/billing",
"message": "Auth is a core service and should not depend on business logic from other features."
}
]
}
]
}
}
Pro Tip: This is a âgentlemanâs agreementâ enforced by a robot. Itâs hacky, but incredibly effective for legacy codebases where a full refactor isnât feasible. Your
ci-runner-02will fail the build, and thatâs the point. It forces the conversation.
Solution 2: The Permanent Fix â True Feature Colocation
This is my preferred approach for any new, large-scale project. Instead of organizing by file *type* (components, hooks, lib), we organize by *feature*. Each feature becomes a vertical slice of the application, containing everything it needs to function.
Hereâs what that folder structure looks like in practice:
/src
|-- /app
| |-- /dashboard (Route)
| |-- /billing (Route)
| `-- layout.tsx
|
|-- /features
| |-- /auth
| | |-- /components
| | | `-- LoginForm.tsx
| | |-- /hooks
| | | `-- useAuth.ts
| | `-- index.ts (Public API for this feature)
| |
| |-- /billing
| | |-- /components
| | | |-- InvoiceList.tsx
| | | `-- SubscriptionCard.tsx
| | |-- /utils
| | | `-- formatCurrency.ts
| | `-- index.ts
|
|-- /lib (Truly generic, app-wide utilities)
`-- /components (Truly generic, UI-kit level components like Button, Input)
In this model, if you want to use something from the billing feature, you import it from @/features/billing. The index.ts file in each feature folder acts as a public API, explicitly exporting what other parts of the app are allowed to consume. Anything not exported is considered private. This creates incredibly clear boundaries and makes it a breeze to see a featureâs entire surface area at a glance.
Solution 3: The âNuclearâ Option â Embracing the Monorepo
Sometimes, an application becomes so massive that even folder-level isolation isnât enough. You have multiple teams, different deployment cadences, and shared packages that need to be versioned. This is where you bring in the heavy artillery: a monorepo, managed with a tool like Turborepo or Nx.
In this setup, your Next.js app is just one package in a larger repository. Each âfeatureâ might be its own package, and your shared UI components would live in another dedicated package.
/my-company-monorepo
|-- /apps
| |-- /web (Your Next.js app)
| `-- /docs (A separate docs site)
|
|-- /packages
| |-- /ui (Your shared React component library, like Storybook)
| |-- /eslint-config-custom (Shared ESLint config)
| |-- /feature-billing (Code for the billing feature)
| `-- /feature-auth (Code for the auth feature)
|
`-- package.json
The Next.js app in /apps/web then declares dependencies on these local packages (âuiâ: âworkspace:\*â, âfeature-billingâ: âworkspace:\*â). This gives you the ultimate level of isolation and reusability.
But be warned, this is not a decision to take lightly. It introduces a new layer of complexity to your tooling and build process.
| Pros of a Monorepo | Cons of a Monorepo |
| â Absolute code isolation. | â Significant tooling overhead (Turborepo, etc). |
| â Versioned, reusable packages. | â Steeper learning curve for the team. |
| â Smart builds (only build what changed). | â Can feel like over-engineering for small-mid sized teams. |
My Take: Donât jump to a monorepo just because itâs trendy. Ask yourself: âDo we have multiple applications sharing this logic, or do we need to version features independently?â If the answer is no, the colocation strategy (Solution #2) will give you 90% of the benefit with 10% of the complexity.
Ultimately, the goal is to make your codebase predictable and safe. You want a developer to be able to work on the âUser Profileâ feature with full confidence they wonât accidentally break the âAdmin Dashboardâ. Pick the strategy that fits your teamâs scale and discipline, and youâll sleep better at night. Trust me.
đ Read the original article on TechResolve.blog
â Support my work
If this article helped you, you can buy me a coffee:

Top comments (0)