🚀 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.json` combined with `eslint-plugin-import` and `no-restricted-paths` rules 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.ts` file 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-02` will 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.
🤖 Frequently Asked Questions
âť“ What is feature-driven folder isolation in Next.js?
Feature-driven folder isolation is an architectural approach in Next.js where the application’s codebase is organized by distinct features rather than by file type. Each feature becomes a self-contained unit, encapsulating its own components, hooks, and logic, to minimize interdependencies and enhance maintainability.
âť“ How do feature-driven folders compare to traditional Next.js project structures?
Traditional Next.js structures often centralize shared assets (e.g., `/components`, `/lib`), which can lead to a ‘soup’ of code where changes in one area unpredictably affect others. Feature-driven folders, conversely, create clear boundaries by co-locating all code related to a specific feature, making it more modular and reducing unintended side effects.
âť“ What is a common implementation pitfall when adopting feature isolation and how can it be avoided?
A common pitfall is developers bypassing the intended isolation by directly importing internal modules from another feature, rather than using its defined public API. This can be avoided by strictly enforcing import rules using `eslint-plugin-import` with `no-restricted-paths` and by clearly defining public APIs for each feature via `index.ts` files.
Leave a Reply