Today I want to share with you a structure I've been perfecting through years of Next.js development.
As applications grow in complexity, the traditional approach of organizing code by technical layers (components, hooks, services) quickly becomes unwieldy. After leading multiple large-scale Next.js projects, I've refined a feature-driven architecture that has consistently improved maintainability, team coordination, and development velocity.
The Problem with Technical Layering
Most tutorials and starter templates organize Next.js applications like this:
src/
├── components/
├── hooks/
├── services/
├── utils/
├── pages/ (or app/)
└── styles/
This works fine for small projects, but as your application grows, you'll notice several issues:
- Scattered related files: Components, hooks, and services that work together are spread across different directories
- Ownership challenges: Teams struggle to own parts of the codebase as features cross-cut directories
- Dependency creep: Without clear boundaries, dependencies between features become implicit and tangled
- Refactoring difficulty: Changing a feature requires modifying multiple directories
Enter Feature-Driven Architecture
The core principle is simple: organize code by what it does, not what it is.
Here's the structure I've found most effective:
src/
├── app/ # Next.js App Router pages and layouts
├── components/ # Truly shared UI components
├── features/ # Feature modules
│ ├── auth/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── types/
│ ├── posts/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── types/
│ └── shared/ # Cross-feature shared code
├── lib/ # Core utilities
└── types/ # Global types
Key Benefits
1. High Cohesion
Related code lives together regardless of its technical type. The authentication form component lives with authentication hooks, services, and types.
2. Clear Boundaries
Each feature exposes a deliberate public API through its index.ts file:
// features/auth/index.ts
export * from './components';
export * from './hooks';
export { signIn, signOut } from './services';
export type { User, AuthCredentials } from './types';
Other features import only what's explicitly exported:
import { SignInForm, useAuth } from '@/features/auth';
This makes dependencies between features explicit and manageable.
3. Team Ownership
Teams can own entire features without worrying about changes in unrelated areas. The auth team can own the entire /features/auth
directory and be confident that other teams won't modify their code.
4. Scalable Organization
This structure works equally well for small projects and large enterprise applications with dozens of developers. As your application grows, you simply add more feature directories without changing the overall architecture.
Implementation Details
Let's look at how a specific feature might be organized:
Authentication Feature
features/auth/
├── components/
│ ├── sign-in.tsx
│ ├── sign-up.tsx
│ └── index.ts
├── hooks/
│ ├── use-auth.ts
│ └── index.ts
├── services/
│ ├── auth.ts
│ └── index.ts
├── types/
│ ├── auth.types.ts
│ └── index.ts
└── index.ts
Each subdirectory has an index.ts file that exports only what should be publicly available:
// features/auth/components/index.ts
export * from './sign-in';
export * from './sign-up';
// Internal components are not exported
The main feature index.ts then re-exports from these subdirectories:
// features/auth/index.ts
export * from './components';
export * from './hooks';
export * from './services';
export * from './types';
Shared Code
There are two places for shared code:
-
/components
: Truly universal UI components like buttons, inputs, and cards -
/features/shared
: Components, hooks, and utilities that are specific to your business domain but used across features
This distinction keeps your shared components directory clean while still allowing for code reuse.
Practical Example: Authentication Flow
Let's see how components, hooks, and services work together within a feature:
// features/auth/components/sign-in.tsx
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAuth } from '../hooks';
import type { SignInCredentials } from '../types';
export function SignIn() {
const [credentials, setCredentials] = useState<SignInCredentials>({
email: '',
password: '',
});
const { signIn, isLoading } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await signIn(credentials);
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
);
}
// features/auth/hooks/use-auth.ts
import { useState } from 'react';
import { signIn as nextAuthSignIn } from 'next-auth/react';
import type { SignInCredentials } from '../types';
import { handleAuthError } from '../services';
export function useAuth() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const signIn = async (credentials: SignInCredentials) => {
try {
setIsLoading(true);
setError(null);
const result = await nextAuthSignIn('credentials', {
...credentials,
redirect: false,
});
if (!result?.ok) {
throw new Error(result?.error || 'Failed to sign in');
}
return result;
} catch (err) {
const errorMessage = handleAuthError(err);
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
};
return {
signIn,
isLoading,
error,
};
}
Notice how everything related to authentication lives together, creating a clean mental model for developers.
Getting Started with Feature-Driven Architecture
I've created a starter template that implements this architecture pattern. You can get started by cloning my GitHub repository:
# Clone the repository
git clone https://github.com/rufatalv/next-feature-based my-feature-app
# Navigate to the project
cd my-feature-app
# Install dependencies
npm install
Architecture Principles to Follow
To maintain the benefits of this architecture, follow these principles:
- Feature Independence: Features should be as independent as possible
- Explicit Dependencies: Features should explicitly import what they need from other features
- Consistent Structure: All features should follow the same internal structure
- Public APIs: Features should expose a clear public API through their index.ts files
- Co-located Tests: Tests should live alongside the code they test
Conclusion
Feature-driven architecture has transformed how my teams build Next.js applications. The initial investment in structure pays huge dividends when you're months into development with multiple developers working on the same codebase.
This approach bridges the gap between technical implementation and business domains, making it easier for developers to reason about the codebase and for product teams to map requirements to code.
I'd love to hear your thoughts and experiences with different architecture patterns. Have you tried feature-driven architecture? What worked and what didn't?
You can find the starter template at https://github.com/rufatalv/next-feature-based.
Top comments (0)