Clean Architecture vs. Feature-Sliced Design in Next.js Applications

Introduction
In the ever-evolving landscape of web development, architectural patterns serve as crucial blueprints for organizing complex applications. Two architectural approaches have gained significant traction in recent years: Clean Architectureand Feature-Sliced Design (FSD). As a computer science researcher specializing in software architecture, I've implemented both patterns across numerous projects and observed their impact on development efficiency, code maintainability, and team collaboration.
This article explores the implementation of both patterns within Next.js applications—highlighting their differences, strengths, and practical applications. We'll examine how these architectures handle business logic, external resources, repositories, and cross-cutting concerns, with concrete examples demonstrating their implementation.
Clean Architecture in Next.js
The Core Principles
Clean Architecture, popularized by Robert C. Martin (Uncle Bob), organizes code into concentric circles, with dependencies pointing inward. The innermost layers represent business rules and domain logic, while the outermost layers handle infrastructure concerns like UI, databases, and external services.

The four fundamental layers in Clean Architecture are:
- Entities: Core business models and domain-specific logic
- Use Cases: Application-specific business rules (also called Interactors)
- Interface Adapters: Translate data between use cases and external frameworks
- Frameworks & Drivers: External frameworks, tools, and delivery mechanisms
Implementation in Next.js
Let's examine how Clean Architecture can be implemented in a Next.js application:
/src
/core
/entities
User.ts
Product.ts
/use-cases
/user
GetUserUseCase.ts
CreateUserUseCase.ts
/product
GetProductsUseCase.ts
/repositories
UserRepository.ts
ProductRepository.ts
/adapters
/controllers
UserController.ts
/gateways
UserAPIGateway.ts
/presenters
UserPresenter.ts
/frameworks
/database
mongodb.ts
/api
userRoutes.ts
/web
/pages
/users
[id].tsx
index.tsx
/components
UserList.tsx
UserDetails.tsx
Business Logic Implementation
In Clean Architecture, business logic is encapsulated within entities and use cases. Let's look at a concrete example:
// core/entities/User.ts
export class User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
constructor(props: Omit<User, 'isAdmin'>) {
Object.assign(this, props);
}
isAdmin(): boolean {
return this.role === 'admin';
}
}
// core/repositories/UserRepository.ts
export interface UserRepository {
findById(id: string): Promise<User | null>;
findAll(): Promise<User[]>;
save(user: User): Promise<User>;
}
// core/use-cases/user/GetUserUseCase.ts
export class GetUserUseCase {
constructor(private userRepository: UserRepository) {}
async execute(userId: string): Promise<User | null> {
return this.userRepository.findById(userId);
}
}
Repository Implementation
// adapters/gateways/UserAPIGateway.ts
import { User } from '@/core/entities/User';
import { UserRepository } from '@/core/repositories/UserRepository';
import { MongoClient } from '@/frameworks/database/mongodb';
export class UserMongoRepository implements UserRepository {
constructor(private mongoClient: MongoClient) {}
async findById(id: string): Promise<User | null> {
const userData = await this.mongoClient.db().collection('users').findOne({ id });
if (!userData) return null;
return new User({
id: userData.id,
name: userData.name,
email: userData.email,
role: userData.role,
});
}
async findAll(): Promise<User[]> {
const usersData = await this.mongoClient.db().collection('users').find().toArray();
return usersData.map(userData => new User({
id: userData.id,
name: userData.name,
email: userData.email,
role: userData.role,
}));
}
async save(user: User): Promise<User> {
await this.mongoClient.db().collection('users').updateOne(
{ id: user.id },
{ $set: { ...user } },
{ upsert: true }
);
return user;
}
}
API and Controller Implementation
// frameworks/api/userRoutes.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { UserController } from '@/adapters/controllers/UserController';
import { GetUserUseCase } from '@/core/use-cases/user/GetUserUseCase';
import { UserMongoRepository } from '@/adapters/gateways/UserAPIGateway';
import { mongoClient } from '@/frameworks/database/mongodb';
const userRepository = new UserMongoRepository(mongoClient);
const getUserUseCase = new GetUserUseCase(userRepository);
const userController = new UserController(getUserUseCase);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const userId = req.query.id as string;
await userController.getUser(userId, res);
}
}
// adapters/controllers/UserController.ts
import { NextApiResponse } from 'next';
import { GetUserUseCase } from '@/core/use-cases/user/GetUserUseCase';
import { UserPresenter } from '@/adapters/presenters/UserPresenter';
export class UserController {
constructor(private getUserUseCase: GetUserUseCase) {}
async getUser(userId: string, res: NextApiResponse) {
try {
const user = await this.getUserUseCase.execute(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
const presenter = new UserPresenter();
const presentedUser = presenter.present(user);
return res.status(200).json(presentedUser);
} catch (error) {
return res.status(500).json({ error: 'Internal server error' });
}
}
}
UI Implementation
// pages/users/[id].tsx
import { GetServerSideProps } from 'next';
import { UserDetails } from '@/frameworks/web/components/UserDetails';
import { GetUserUseCase } from '@/core/use-cases/user/GetUserUseCase';
import { UserMongoRepository } from '@/adapters/gateways/UserAPIGateway';
import { mongoClient } from '@/frameworks/database/mongodb';
import { User } from '@/core/entities/User';
import { UserPresenter } from '@/adapters/presenters/UserPresenter';
interface Props {
user: ReturnType<UserPresenter['present']> | null;
}
export default function UserPage({ user }: Props) {
if (!user) {
return <div>User not found</div>;
}
return <UserDetails user={user} />;
}
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
const userId = context.params?.id as string;
const userRepository = new UserMongoRepository(mongoClient);
const getUserUseCase = new GetUserUseCase(userRepository);
const user = await getUserUseCase.execute(userId);
if (!user) {
return { props: { user: null } };
}
const presenter = new UserPresenter();
const presentedUser = presenter.present(user);
return {
props: {
user: presentedUser,
},
};
};
Feature-Sliced Design in Next.js
The Core Principles
Feature-Sliced Design (FSD) is a methodology that organizes code by features rather than technical layers. It was developed to address some of the challenges faced by large teams working on complex applications. FSD structures the application into slices (features), where each slice contains all the code necessary for a specific feature to function.

The key layers in FSD are:
- App: Global application settings, providers, and styles
- Processes: Complex flows involving multiple features
- Pages: Compositional layer connecting features to specific routes
- Widgets: Composite UI blocks combining multiple entities/features
- Features: User scenarios, business capabilities
- Entities: Business objects and their logic
- Shared: Reusable utilities, UI kit, API clients, and types
Implementation in Next.js
Here's how a Feature-Sliced Design structure might look in a Next.js application:
/src
/app
/providers
with-auth.tsx
with-theme.tsx
/styles
globals.css
/processes
/auth
/model
auth-process.ts
/pages
/users
[id].tsx
index.tsx
/products
[id].tsx
index.tsx
/widgets
/user-profile
/ui
user-profile.tsx
/model
types.ts
/features
/user
/create-user
/ui
create-user-form.tsx
/model
create-user.ts
validation.ts
/api
create-user.ts
/edit-user
/ui
edit-user-form.tsx
/model
edit-user.ts
/api
edit-user.ts
/entities
/user
/ui
user-card.tsx
user-avatar.tsx
/model
user.ts
user-schema.ts
/api
user-api.ts
/shared
/ui
button.tsx
input.tsx
/api
base-api.ts
/lib
hooks.ts
/config
constants.ts
Business Logic Implementation in FSD
In Feature-Sliced Design, business logic is distributed across the entities and features layers:
// entities/user/model/user.ts
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
export const isAdmin = (user: User): boolean => {
return user.role === 'admin';
};
// entities/user/api/user-api.ts
import { User } from '../model/user';
import { baseApi } from '@/shared/api/base-api';
export const userApi = {
getById: async (id: string): Promise<User | null> => {
try {
const response = await baseApi.get<User>(`/users/${id}`);
return response.data;
} catch (error) {
console.error('Failed to fetch user:', error);
return null;
}
},
getAll: async (): Promise<User[]> => {
try {
const response = await baseApi.get<User[]>('/users');
return response.data;
} catch (error) {
console.error('Failed to fetch users:', error);
return [];
}
}
};
// features/user/create-user/model/create-user.ts
import { User } from '@/entities/user/model/user';
import { userApi } from '@/entities/user/api/user-api';
export interface CreateUserForm {
name: string;
email: string;
role: 'admin' | 'user';
}
export const createUser = async (userData: CreateUserForm): Promise<User | null> => {
try {
const response = await userApi.create(userData);
return response;
} catch (error) {
console.error('Failed to create user:', error);
return null;
}
};
UI Implementation in FSD
// entities/user/ui/user-card.tsx
import React from 'react';
import { User, isAdmin } from '../model/user';
import { Button } from '@/shared/ui/button';
interface UserCardProps {
user: User;
onEdit?: () => void;
}
export const UserCard: React.FC<UserCardProps> = ({ user, onEdit }) => {
return (
<div className="p-4 border rounded-md">
<h3 className="text-lg font-bold">{user.name}</h3>
<p>{user.email}</p>
{isAdmin(user) && <span className="text-sm bg-blue-100 px-2 py-1 rounded">Admin</span>}
{onEdit && <Button onClick={onEdit}>Edit</Button>}
</div>
);
};
// features/user/edit-user/ui/edit-user-form.tsx
import React, { useState } from 'react';
import { User } from '@/entities/user/model/user';
import { Input } from '@/shared/ui/input';
import { Button } from '@/shared/ui/button';
import { updateUser } from '../model/edit-user';
interface EditUserFormProps {
user: User;
onSuccess: (updatedUser: User) => void;
}
export const EditUserForm: React.FC<EditUserFormProps> = ({ user, onSuccess }) => {
const [formData, setFormData] = useState({
name: user.name,
email: user.email,
role: user.role,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const updatedUser = await updateUser(user.id, formData);
if (updatedUser) {
onSuccess(updatedUser);
}
};
return (
<form onSubmit={handleSubmit}>
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<Input
label="Email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
<select
value={formData.role}
onChange={(e) => setFormData({
...formData,
role: e.target.value as 'admin' | 'user'
})}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<Button type="submit">Save Changes</Button>
</form>
);
};
Next.js Page Implementation in FSD
// pages/users/[id].tsx
import { GetServerSideProps } from 'next';
import { User } from '@/entities/user/model/user';
import { userApi } from '@/entities/user/api/user-api';
import { UserProfile } from '@/widgets/user-profile/ui/user-profile';
import { withAuth } from '@/app/providers/with-auth';
interface Props {
user: User | null;
}
function UserPage({ user }: Props) {
if (!user) {
return <div>User not found</div>;
}
return <UserProfile user={user} />;
}
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
const userId = context.params?.id as string;
const user = await userApi.getById(userId);
return {
props: {
user,
},
};
};
export default withAuth(UserPage);
Key Differences Between Clean Architecture and FSD
1. Organizational Philosophy
Clean Architecture:
- Organizes code by technical responsibility
- Enforces strict dependency rules (dependencies point inward)
- Emphasizes isolation of business logic from infrastructure
- Has concentric layers rather than vertical slices
Feature-Sliced Design:
- Organizes code by business features or user capabilities
- Groups related code together regardless of technical role
- Uses horizontal layers but within vertical slices (features)
- Focuses on business domains rather than technical concerns
2. Handling Business Logic
Clean Architecture:
- Centralizes business logic in entities and use cases
- Abstracts away infrastructure through repositories and interfaces
- Business rules are isolated and independent of UI/frameworks
- Requires extensive use of interfaces and dependency injection
Feature-Sliced Design:
- Distributes business logic across entities and features
- Couples business logic more closely with its UI representation
- Uses model folders within features to handle specific business operations
- Less emphasis on abstraction, more on practical organization
3. Adaptability and Team Collaboration
Clean Architecture:
- Highly adaptable to changing infrastructure (databases, frameworks)
- Requires more upfront design and boilerplate
- Can be challenging for new team members to understand
- Better suited for long-lived projects with complex business rules
Feature-Sliced Design:
- Highly adaptable to changing requirements and features
- Facilitates team collaboration through feature ownership
- Easier for new developers to understand and contribute to specific features
- Better suited for rapid development and feature-based teams
4. Data Flow
Clean Architecture:
UI → Controller → Use Case → Entities → Repository → Data Source
Feature-Sliced Design:
Pages → Widgets → Features → Entities → API Clients → External Services
When to Choose Each Architecture
Choose Clean Architecture When:
- Your application has complex business rules that rarely change
- You need maximum flexibility to swap out infrastructure components
- You want to ensure your core business logic is thoroughly testable
- You're building a long-lived system that will undergo multiple infrastructure changes
- Your team is experienced with design patterns and dependency injection
Choose Feature-Sliced Design When:
- Your application is feature-rich with moderate business complexity
- You need to scale development across multiple teams
- Features are more likely to change than core business rules
- You want to minimize the learning curve for new developers
- You're building a user-facing application with many UI components
Hybrid Approach: Combining the Best of Both Worlds
In practice, many Next.js applications benefit from a hybrid approach:
/src
/core # Clean Architecture core
/entities
/use-cases
/repositories
/features # Feature-Sliced organization
/users
/create-user
/edit-user
/products
/product-list
/product-details
/widgets # Shared UI compositions
/pages # Next.js pages
/shared # Shared utilities and components
/ui
/lib
/api
This hybrid approach:
- Maintains clean separation of core business logic
- Organizes UI and user interactions by feature
- Leverages Next.js's page-based routing
- Preserves shared components and utilities
Conclusion
Both Clean Architecture and Feature-Sliced Design offer powerful paradigms for structuring Next.js applications. Clean Architecture excels at preserving business rules and maintaining flexibility, while Feature-Sliced Design optimizes for developer experience and team collaboration.
The choice between these architectures should be guided by your specific project requirements, team composition, and long-term maintenance needs. For many projects, a hybrid approach that leverages the strengths of both patterns can provide the optimal balance between maintainability, scalability, and developer productivity.
As web applications continue to grow in complexity, thoughtful architectural decisions become increasingly crucial. By understanding these patterns and their implementations in Next.js, you can make informed choices that set your projects up for long-term success.