DEV Community

Cover image for Solved: How do you handle feature-driven folder isolation in large Next.js apps?
Darian Vance
Darian Vance

Posted on • Originally published at wp.me

Solved: How do you handle feature-driven folder isolation in large Next.js apps?

🚀 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/*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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."
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.


Darian Vance

👉 Read the original article on TechResolve.blog


☕ Support my work

If this article helped you, you can buy me a coffee:

👉 https://buymeacoffee.com/darianvance

Top comments (0)