Skip to content

An Enterprise Folder Structure for Next.js

Aman Kumar Singh8 min read
Part 41 of 50From the React & Next.js Complete Guide series
An Enterprise Folder Structure for Next.js — article by Aman Kumar Singh

The last article covered end-to-end testing with Playwright, which gives the app a safety net for the flows that matter. That net doesn't fix a codebase that's hard to navigate, though. It just tells you faster when something breaks. This article is about the thing that determines how long it takes a new engineer to find the right file, and how long it takes an existing one to trust that a change won't ripple into three unrelated places.

This is part of the React & Next.js Complete Guide, picking up where the App Router and Server Components articles left off. Those covered how routing and rendering work. This one covers how to lay out a Next.js codebase so it still makes sense at ten teams and forty routes, not just the three routes and one team it probably started with.

I'm assuming you already know the App Router basics: route groups, layouts, and file conventions like page.tsx and loading.tsx. What follows is the layer above that: how to organize everything that isn't a route file, and how to keep that organization from decaying as the app grows.

Why folder structure is an architecture decision, not a style preference

Folder structure gets treated as bikeshedding more often than any other technical decision, mostly because it doesn't show up in a demo and it doesn't fail a test. But structure determines the blast radius of a change. In a well-structured codebase, adding a field to an invoice touches one folder. In a poorly structured one, it touches a types/ folder, a hooks/ folder, a components/ folder, and an api/ folder scattered across the tree with no obvious relationship to each other.

The cost compounds with team size. A single engineer can hold a flat, type-based structure in their head because they wrote all of it. Ten engineers across three teams can't, because nobody has full context on every folder anymore. At that point the folder tree either encodes ownership boundaries, or ownership lives only in people's memory and Slack threads, which doesn't scale and doesn't survive someone leaving the team.

The goal of an enterprise structure is to make the boundary between what a feature owns and what it doesn't visible in the file tree, so the codebase itself teaches new engineers where things belong. Cleverness has nothing to do with it.

Let the App Router do the organizing it's already built for

Next.js's routing is filesystem-based, which means the router already encodes a chunk of your architecture whether you plan for it or not. Two conventions do most of the heavy lifting once you're past a toy app: route groups for organizing by area without affecting the URL, and private folders (prefixed with an underscore) for colocating code the router should ignore.

apps/web/src/app/
├── (marketing)/
│   ├── page.tsx
│   ├── pricing/
│   │   └── page.tsx
│   └── layout.tsx
├── (dashboard)/
│   ├── layout.tsx
│   ├── invoices/
│   │   ├── page.tsx
│   │   ├── [invoiceId]/
│   │   │   └── page.tsx
│   │   └── _components/
│   │       ├── invoice-table.tsx
│   │       └── invoice-status-badge.tsx
│   └── billing/
│       ├── page.tsx
│       └── _components/
├── (auth)/
│   ├── sign-in/
│   │   └── page.tsx
│   └── sign-up/
│       └── page.tsx
└── layout.tsx

The _components folders inside each route are deliberate. Next.js ignores any folder starting with an underscore for routing, so you can colocate a component next to invoices/page.tsx without accidentally creating a route at /invoices/components. Colocation is the cheapest ownership signal available: if invoice-status-badge.tsx only ever gets imported from within invoices/, putting it in invoices/_components/ rather than a global components/ folder tells the next reader that, without a comment.

Route groups do the equivalent job one level up. (dashboard) and (marketing) don't appear in the URL, but they let you attach a different layout or auth requirement to a whole section of the app without those concerns leaking into unrelated routes. Treat route groups as top-level areas of the product, and let everything below them be organized by feature.

Feature modules: the actual unit of ownership

Route folders are a good default for page-level UI, but they get cramped once a feature has real domain logic: hooks, server actions, validation schemas, types shared across routes. Promote features that outgrow a single route folder into a proper module under a features/ directory, and have routes import from it rather than own the logic directly.

apps/web/src/features/invoices/
├── components/
│   ├── invoice-table.tsx
│   └── invoice-status-badge.tsx
├── hooks/
│   └── use-invoices.ts
├── actions/
│   └── create-invoice.ts
├── schemas/
│   └── invoice.schema.ts
├── types.ts
└── index.ts

The index.ts at the root is the module's public API. Everything else in the folder is a private implementation detail that other features should never import directly.

// apps/web/src/features/invoices/index.ts
export { InvoiceTable } from "./components/invoice-table";
export { InvoiceStatusBadge } from "./components/invoice-status-badge";
export { useInvoices } from "./hooks/use-invoices";
export { createInvoice } from "./actions/create-invoice";
export type { Invoice, InvoiceStatus } from "./types";

A route file then imports the feature the same way it would import a third-party package:

// apps/web/src/app/(dashboard)/invoices/page.tsx
import { InvoiceTable, useInvoices } from "@/features/invoices";

export default function InvoicesPage() {
  return <InvoiceTable />;
}

This is the same idea as an NPM package boundary, enforced inside a single app instead of across a monorepo. The feature can rename use-invoices.ts, split a hook in two, or swap its data layer, and as long as index.ts still exports the same names, nothing outside the feature needs to know. Without that discipline, other parts of the app import features/invoices/hooks/use-invoices directly, and every internal rename becomes a cross-team breaking change.

Shared code deserves to be small on purpose

Every codebase accumulates a shared/, lib/, or common/ folder, and the temptation is to treat it as the place things go when you're not sure where else to put them. Resist that. A shared folder that grows without discipline becomes a second, undocumented feature module, except one that every other feature depends on, which makes it the most expensive part of the codebase to change.

Keep three narrow categories under shared code, and push back on anything that doesn't fit one of them:

  • ui/, genuinely generic, presentation-only components with no domain knowledge: buttons, dialogs, form primitives. If a component imports an Invoice type, it isn't generic UI anymore, and it belongs in a feature.
  • lib/, small utilities with no side effects: date formatting, class-name merging, a typed fetch wrapper. Nothing that talks to a specific feature's API.
  • config/, environment access and app-wide constants, ideally the single place that reads process.env so the rest of the app never does it directly.

A feature is allowed to depend on shared code. Shared code should never depend on a feature. That single rule, checked in review if you don't have automated tooling for it yet, prevents the slow drift where lib/ quietly grows an invoice-utils.ts file that only makes sense with knowledge of the invoices domain.

Enforcing boundaries so they survive contact with deadlines

A folder convention that only lives in a README decays the first time someone is under deadline pressure and imports across a boundary because it's faster. The fix is making the boundary a build-time check. eslint-plugin-boundaries is a common way to do this, letting you declare which folder types can import from which others.

// eslint.config.js (excerpt)
export default [
  {
    settings: {
      "boundaries/elements": [
        { type: "app", pattern: "src/app/**" },
        { type: "feature", pattern: "src/features/*", capture: ["name"] },
        { type: "shared", pattern: "src/{ui,lib,config}/**" },
      ],
    },
    rules: {
      "boundaries/element-types": [
        "error",
        {
          default: "disallow",
          rules: [
            { from: "app", allow: ["feature", "shared"] },
            { from: "feature", allow: ["shared"] },
            { from: "shared", allow: [] },
          ],
        },
      ],
    },
  },
];

With this in place, a feature importing directly from another feature's internal hooks/ folder fails CI instead of merging quietly, a stronger guarantee than a review comment since it doesn't depend on anyone noticing.

The tradeoff is upfront setup cost and occasional friction when a feature genuinely needs another feature's public export. That's fine; add it to the allow list for that pair explicitly, so the exception is visible in the config rather than invisible in someone's import statement.

Production pitfalls worth knowing before they bite

A few structural mistakes show up repeatedly once an app scales past its first few contributors, and they're each easy to avoid once you know to look for them.

Barrel files that re-export everything. An index.ts that does export * from "./components" for a folder with thirty components looks convenient, but it can drag your bundler into resolving far more modules than a given page actually uses, showing up as slower builds and, on some bundler configurations, larger client bundles. Keep barrel exports explicit and scoped to what a feature intends to expose, not a blanket re-export of an entire folder.

Premature feature extraction. Not every route needs a features/ module. A settings page with one form and no reused logic is fine living entirely inside its route folder with a _components subfolder. Promote something to a feature module when a second route needs to reuse its logic, not before. Structure imposed before it's earned adds indirection with no payoff.

Global types/ and utils/ folders as a dumping ground. These two folder names tend to become the place a type or function goes when nobody wants to decide which feature owns it. A type describing a domain concept, invoice, organization, subscription, belongs in that feature's types.ts, not in a project-wide types folder that eventually holds two hundred unrelated interfaces.

Route groups used as feature boundaries. Route groups organize pages and layouts, not logic. They can't hold hooks, server actions, or business logic in a way other parts of the app can safely import. Using them for that job usually means logic ends up scattered across multiple _components folders instead of one owned location.

Key takeaways

  • Folder structure is an architecture decision that determines blast radius, not a cosmetic preference; it matters most as team size grows.
  • Use the App Router's route groups and underscore-prefixed private folders for page-level organization and colocation.
  • Promote logic to a `features/` module once more than one route needs it, and give each module an `index.ts` that acts as its public API.
  • Keep `shared/`, `lib/`, and `ui/` narrow and one-directional: features can depend on shared code, shared code should never depend on a feature.
  • Enforce the boundary with a tool like `eslint-plugin-boundaries` so it survives deadline pressure instead of relying on review discipline alone.
  • Avoid barrel files that re-export entire folders, and resist creating a feature module before a second consumer actually needs one.

Frequently asked questions

Should every Next.js app use a `features/` folder from day one?

No. A small app with a handful of routes and no reused domain logic is fine colocated entirely inside `app/`. Introduce a `features/` folder once a hook, component, or server action needs to be used by more than one route. Structure imposed before it's needed just adds navigation overhead.

What's the difference between a route group and a feature module?

A route group, the parenthesized folders like `(dashboard)`, is a routing and layout construct: it changes which layout wraps a set of pages without changing the URL. A feature module is a code-organization construct holding hooks, actions, and components tied to a domain concept, importable from multiple routes. They solve different problems and aren't interchangeable.

How does this structure change in a monorepo with a separate NestJS API?

The principles hold, feature-based organization, a public API per module, one-directional dependencies, but the boundary moves up a level to an actual package boundary: `apps/web`, `apps/api`, and shared code in `packages/`, each with its own `package.json`. The `features/` folder inside `apps/web` still applies.

Does colocating components inside `_components` folders hurt reusability?

Only if you colocate something genuinely reused elsewhere. The point of `_components` is signaling a component is private to one route or feature. If a second place later needs it, that's the signal to promote it into `shared/ui/` if it's generic. Colocation is a default, cheap to change later, not a permanent decision.

Is `eslint-plugin-boundaries` overkill for a small team?

For a two- or three-person team shipping quickly, a documented convention and code review are probably enough, and the setup cost may not pay for itself yet. It earns its keep once you have multiple teams or contributors who don't have full context on every feature, because a lint error at PR time is far more reliable than hoping every reviewer catches a cross-feature import.

Related articles

Building a UI with shadcn/ui — Aman Kumar Singh
Server Components in Practice — Aman Kumar Singh
Compound Components and Slots — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.