Component architecture that survives a growing team requires strict boundaries. Learn how we scaled our React/Next.js codebase to keep features moving fast.

When you’re the sole engineer on a Next.js project, "organized" usually just means having a components/ folder. But as soon as your team hits four or five people, that folder becomes a graveyard of Button2.tsx, Header_v2.tsx, and files nobody dares delete.
I’ve spent the last six months navigating this exact transition. We went from a manageable repo to one with over 150 components and a team that kept stepping on each other's toes. Here is how we stabilized our architecture.
We started by trying to keep things atomic. We had a components/ directory with subfolders like forms/, buttons/, and layout/. It felt clean at first, but it didn't scale. If a developer needed to change the OrderSummary component, they had to hunt through five different folders to find the associated hooks, types, and sub-components.
We eventually pivoted to a feature-first approach. Instead of grouping by type, we group by domain.
TEXTsrc/ features/ checkout/ components/ hooks/ api/ types.ts index.ts components/ <-- Only truly global, shared UI primitives Button.tsx Input.tsx
By moving domain-specific logic into features/, we reduced the cognitive load significantly. If you’re working on the checkout flow, you stay in the checkout folder. This drastically lowers the chance of merge conflicts and makes the codebase easier to navigate for new hires.
The biggest killer of velocity in a growing team is "prop drilling" and tight coupling. When we first started, we were passing state through four levels of components just to update a user's preference. It was a nightmare to debug.
We solved this by leaning harder into Fetching data in a React component the right way. By keeping data-fetching logic close to the components that need it—or lifting it into Server Components—we stopped passing massive objects down the tree.
If you’re still relying on useEffect to manage complex internal states, you’re likely creating a maintenance burden. We moved toward a pattern where:
Instead of a Card component that accepts 20 boolean props (e.g., hasShadow, isHoverable, showBorder), we use the "Slot" pattern.
TSX// Instead of this: <Card hasShadow={true} showBorder={false} /> // Use this: <Card> <Card.Header>Title</Card.Header> <Card.Content>Body content</Card.Content> </Card>
This makes the components much more flexible. A developer can add a new piece of UI to the card without having to modify the Card component's source code, which reduces the surface area for bugs.

The components/ folder at the root is now reserved for "dumb" UI primitives. If a component in there needs to know about our business logic or our API schemas, it doesn't belong in the global folder.
This forces us to think about the dependency graph. A Button shouldn't know about UserContext. If it needs to, it's a feature-specific component, not a global one. We enforce this with eslint-plugin-import rules that prevent components in features/ from importing from other features' internal files.
It’s about roughly 80% of our code being isolated. If we need to refactor a feature, we know exactly where the blast radius is.
We tried implementing a massive, shared UI library early on. We spent about two weeks building a custom design system instead of shipping features. It was a mistake. We didn't know what our requirements actually were, so we ended up with a library that was too rigid for some use cases and too bloated for others.
If I were starting over today, I’d wait until we had at least three instances of a component before abstracting it into a shared library. Don't optimize for the "What If" scenarios; optimize for the "Right Now" scenarios.
Is our architecture perfect? No. We still have a few legacy components that make me cringe during code reviews. But by enforcing domain boundaries and favoring composition over prop-heavy configuration, we’ve managed to keep our velocity high despite the team doubling in size over the last year.
Next time, I might experiment with a monorepo structure using Turborepo to enforce these boundaries at the package level, but that’s a conversation for another day. For now, feature-first directories are doing the heavy lifting.

How do you handle shared types?
We keep a types/ folder at the root for truly global types (like User or Session). Feature-specific types live inside the feature folder. If a type is used by two features, it moves to the global folder.
Do you ever regret using feature-first folders?
Only when a feature becomes too large. If a feature folder grows beyond 10-15 files, we break it down into sub-features or move shared utilities into a shared/ folder. It’s a constant balancing act.
Does this increase bundle size? Not significantly. Since Next.js handles code splitting automatically, keeping components in their respective feature folders actually helps ensure that code is only loaded when the user visits that specific part of the application.
Master streaming and Suspense in Next.js to drastically improve perceived performance. Learn how to stream UI components for a faster, responsive experience.