Олег Марков
Feature Sliced Design для Next.js - как организовать масштабируемый фронтенд
Введение
Feature-Sliced Design (FSD) за последние годы стал одним из самых обсуждаемых подходов к архитектуре фронтенда. Он особенно хорошо ложится на React-приложения с растущей сложностью: раздувающиеся компоненты, хаотичные директории, бесконечные «перекидывания» пропсов.
Но когда вы начинаете применять его в Next.js, появляется ряд вопросов:
- как совместить FSD со встроенными правилами маршрутизации Next.js
- где хранить страницы, если в FSD основная единица — фича
- как использовать новый App Router и серверные компоненты вместе с FSD
- какие директории заводить и как называть слои
Смотрите, в этой статье я покажу вам, как адаптировать Feature-Sliced Design под Next.js (далее будем называть это nextjs-fsd), на живых примерах структуры и кода. Вы увидите, как можно построить проект так, чтобы:
- страница была только «сборщиком» интерфейса
- бизнес-логика и UI были разложены по слоям
- маршрутизация Next.js не ломала архитектуру, а дополняла ее
Будем рассматривать подход в контексте нового App Router (app/ директория), потому что именно он сейчас является рекомендуемым способом разработки на Next.js.
Базовые принципы Feature-Sliced Design
Что такое Feature-Sliced Design в двух словах
FSD — это способ организовать фронтенд-проект так, чтобы:
- код был разбит по функциональности, а не по типам файлов
- уровень абстракции повышался от «мелкой логики» к крупным сценариям
- зависимости шли только вверх по слоям (от более низких к более высокоуровневым)
Классические слои FSD:
- shared — переиспользуемые примитивы, утилиты, дизайн-система
- entities — бизнес-сущности (User, Product, Order и т.д.)
- features — законченные пользовательские возможности (Login, AddToCart)
- widgets — крупные блоки страницы (Header, CartPanel, ProductGallery)
- pages — конкретные страницы, собранные из виджетов и фич
- app — точка входа, конфигурация, провайдеры
В Next.js уже есть свой app/ или pages/, поэтому FSD немного адаптируем.
Как FSD стыкуется с Next.js
Для Next.js с App Router удобно использовать следующую верхнеуровневую структуру:
src/
app/ // маршрутизация Next.js
shared/
entities/
features/
widgets/
processes/ // опционально - сложные сценарии
pages/ // "страницы как слой FSD", но не как маршруты Next.js
Здесь важно разделить:
app/— маршруты и layout’ы Next.js (файлыpage.tsx,layout.tsx,loading.tsx)pages/(слой FSD) — React-компоненты страниц, собранные из виджетов и фич
То есть реальный маршрут Next.js (/app/profile/page.tsx) может просто импортировать FSD-страницу (src/pages/profile) и использовать ее как основной контент.
Структура nextjs-fsd проекта
Общий скелет проекта
Давайте посмотрим, как может выглядеть базовая структура Next.js приложения с FSD:
src/
app/
layout.tsx
page.tsx // route: /
profile/
page.tsx // route: /profile
shared/
ui/
button/
index.ts
button.tsx
input/
index.ts
input.tsx
config/
api.ts
env.ts
lib/
fetcher.ts
format-date.ts
styles/
globals.css
entities/
user/
model/
types.ts
selectors.ts
hooks.ts
api/
get-current-user.ts
ui/
user-card/
index.ts
user-card.tsx
features/
auth/
login-by-email/
model/
use-login.ts
ui/
login-form/
index.ts
login-form.tsx
widgets/
header/
ui/
header.tsx
user-profile/
ui/
user-profile.tsx
pages/
home/
ui/
home-page.tsx
profile/
ui/
profile-page.tsx
Смотрите, здесь важные моменты:
src/app— отвечает за маршрутизацию и интеграцию с платформой Next.js- остальные слои (
shared,entities,features,widgets,pages) — чистый React + бизнес-логика по правилам FSD pages/*в FSD — не маршруты, а компоненты-страницы (для сборки интерфейса)
Теперь давайте разберем каждый слой детальнее.
Слой shared в nextjs-fsd
Назначение слоя shared
Слой shared содержит то, что может использоваться в любом месте проекта и при этом:
- не зависит от бизнес-домена
- не знает о конкретных сущностях (User, Product и т.д.)
- часто представляет собой дизайн-систему, хелперы, конфигурацию
Можно ориентироваться на такое деление:
shared/ui— базовые UI-компонентыshared/lib— утилиты и хелперыshared/config— конфигурация (endpoints, environment)shared/api— общая настройка клиента (например, axios instance)shared/styles— глобальные стили, темы
Пример простого UI-компонента
Теперь вы увидите, как это выглядит в коде. Допустим, у вас есть общий Button.
// src/shared/ui/button/button.tsx
import { ButtonHTMLAttributes } from "react";
import clsx from "clsx";
type ButtonVariant = "primary" | "secondary";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; // Вариант отображения кнопки
}
export const Button = ({ variant = "primary", className, ...props }: ButtonProps) => {
return (
<button
// Здесь мы собираем классы: базовые стили + конкретный вариант + дополнительные классы
className={clsx(
"rounded px-4 py-2 text-sm font-medium",
variant === "primary" && "bg-blue-600 text-white hover:bg-blue-700",
variant === "secondary" && "bg-gray-200 text-gray-900 hover:bg-gray-300",
className
)}
{...props} // Пробрасываем все остальные пропсы дальше
/>
);
};
// src/shared/ui/button/index.ts
export { Button } from "./button";
// Здесь мы экспортируем компонент через index.ts,
// чтобы снаружи импорт выглядел короче: import { Button } from "@/shared/ui/button";
Такой Button может использоваться в любом слое: в сущностях, фичах, виджетах и страницах.
Пример общей конфигурации API
Еще один пример — конфигурация HTTP-клиента, которая может использоваться во всем приложении.
// src/shared/config/api.ts
// Здесь мы храним базовый URL для API
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "https://api.example.com";
// src/shared/lib/fetcher.ts
// Здесь мы создаем универсальную функцию-обертку над fetch
export async function apiFetch<T>(
input: string,
init?: RequestInit
): Promise<T> {
const response = await fetch(input, {
// Добавляем к запросу базовую конфигурацию
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
...init,
});
if (!response.ok) {
// Если сервер вернул ошибку - бросаем исключение
throw new Error(`Request failed with status ${response.status}`);
}
// Преобразуем JSON в типизированный ответ
return response.json() as Promise<T>;
}
Слой entities: работа с бизнес-сущностями
Что такое сущность в FSD
Сущность (entity) — это бизнес-объект, вокруг которого строится логика: пользователь, товар, заказ, статья и т.д. У сущности есть:
- типы данных
- API-запросы к серверу
- состояния и селекторы
- базовые UI-компоненты, завязанные на конкретную сущность
Главное — сущность не знает о сценариях использования, она просто описывает «что такое User» или «что такое Product».
Пример сущности User
Давайте разберемся на примере User.
// src/entities/user/model/types.ts
// Здесь мы описываем тип пользователя,
// который будем использовать во всем приложении
export interface User {
id: string;
email: string;
name: string;
avatarUrl?: string;
}
// src/entities/user/api/get-current-user.ts
import { API_BASE_URL } from "@/shared/config/api";
import { apiFetch } from "@/shared/lib/fetcher";
import { User } from "../model/types";
// Здесь мы описываем запрос к API для получения текущего пользователя
export async function getCurrentUser(): Promise<User | null> {
try {
const user = await apiFetch<User>(`${API_BASE_URL}/me`, {
credentials: "include", // Добавляем cookie к запросу
});
return user;
} catch (error) {
// Если запрос не удался (например, пользователь не авторизован) - возвращаем null
return null;
}
}
// src/entities/user/ui/user-card/user-card.tsx
import Image from "next/image";
import { User } from "../../model/types";
interface UserCardProps {
user: User; // Пользователь, которого мы отображаем
}
export const UserCard = ({ user }: UserCardProps) => {
return (
<div className="flex items-center gap-3">
{user.avatarUrl && (
<Image
src={user.avatarUrl}
alt={user.name}
width={40}
height={40}
className="rounded-full"
/>
)}
<div className="flex flex-col">
{/* Здесь мы показываем имя пользователя */}
<span className="font-medium">{user.name}</span>
{/* Здесь мы показываем email пользователя */}
<span className="text-sm text-gray-500">{user.email}</span>
</div>
</div>
);
};
// src/entities/user/ui/user-card/index.ts
export { UserCard } from "./user-card";
Смотрите, сущность user уже умеет:
- описывать структуру данных
- запрашивать данные о текущем пользователе
- отображать пользователя в виде компонента
UserCard
Но при этом она не реализует сценариев входа в систему, смены пароля и т.д. Это уже задачи фич.
Слой features: пользовательские возможности
Идея слоя features
Фича (feature) — это конкретная пользовательская возможность:
- «Войти по email и паролю»
- «Добавить товар в корзину»
- «Отфильтровать список»
- «Поставить лайк»
Фича может использовать сущности (entities) и shared, но не должна напрямую зависеть от widgets или pages.
Структурно можно разделить:
model— бизнес-логика фичи (хуки, сторы, эффекты)ui— компоненты интерфейса фичи
Пример фичи «Login по email»
Покажу вам, как это реализовано на практике.
// src/features/auth/login-by-email/model/use-login.ts
"use client"; // Фича использует client-компоненты и хуки
import { useState } from "react";
interface LoginFormValues {
email: string;
password: string;
}
interface UseLoginResult {
values: LoginFormValues;
isLoading: boolean;
error: string | null;
handleChange: (field: keyof LoginFormValues, value: string) => void;
handleSubmit: () => Promise<void>;
}
export function useLogin(onSuccess?: () => void): UseLoginResult {
// Здесь мы храним значения формы
const [values, setValues] = useState<LoginFormValues>({
email: "",
password: "",
});
// Здесь мы храним состояние загрузки
const [isLoading, setIsLoading] = useState(false);
// Здесь мы храним текст ошибки, если она произошла
const [error, setError] = useState<string | null>(null);
function handleChange(field: keyof LoginFormValues, value: string) {
// Обновляем только одно поле формы
setValues((prev) => ({ ...prev, [field]: value }));
}
async function handleSubmit() {
setIsLoading(true);
setError(null);
try {
// Здесь вы можете вызвать реальный API авторизации
// В примере для простоты мы просто ждем полсекунды
await new Promise((resolve) => setTimeout(resolve, 500));
// Если авторизация прошла успешно - вызываем колбек
onSuccess?.();
} catch (e) {
// В случае ошибки сохраняем текст ошибки
setError("Не удалось выполнить вход. Попробуйте еще раз.");
} finally {
// В любом случае снимаем флаг загрузки
setIsLoading(false);
}
}
return {
values,
isLoading,
error,
handleChange,
handleSubmit,
};
}
// src/features/auth/login-by-email/ui/login-form/login-form.tsx
"use client"; // Компонент использует хуки, значит должен быть клиентским
import { FormEvent } from "react";
import { Button } from "@/shared/ui/button";
import { useLogin } from "../../model/use-login";
interface LoginFormProps {
onSuccess?: () => void; // Колбек при успешном входе
}
export const LoginForm = ({ onSuccess }: LoginFormProps) => {
const { values, isLoading, error, handleChange, handleSubmit } = useLogin(
onSuccess
);
async function onSubmit(event: FormEvent) {
// Отменяем стандартное поведение формы (перезагрузку страницы)
event.preventDefault();
// Вызываем логику отправки формы
await handleSubmit();
}
return (
<form onSubmit={onSubmit} className="space-y-4 max-w-sm">
<div className="flex flex-col gap-1">
{/* Поле email */}
<label className="text-sm font-medium">Email</label>
<input
type="email"
value={values.email}
onChange={(event) => handleChange("email", event.target.value)}
className="border rounded px-3 py-2 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
{/* Поле password */}
<label className="text-sm font-medium">Пароль</label>
<input
type="password"
value={values.password}
onChange={(event) => handleChange("password", event.target.value)}
className="border rounded px-3 py-2 text-sm"
/>
</div>
{/* Блок с ошибкой, если она есть */}
{error && <p className="text-sm text-red-500">{error}</p>}
{/* Кнопка отправки формы */}
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? "Входим..." : "Войти"}
</Button>
</form>
);
};
// src/features/auth/login-by-email/ui/login-form/index.ts
export { LoginForm } from "./login-form";
Теперь у вас есть фича, которая:
- инкапсулирует логику авторизации
- предоставляет готовый UI-компонент
- может использоваться на любой странице, без копипасты форм
Слой widgets: крупные блоки страницы
Что делают виджеты
Виджет (widget) — это крупный, но все еще переиспользуемый блок, который:
- может объединять несколько фич и сущностей
- может использовать контекст приложения
- не является целой страницей, но уже представляет собой значимый кусок интерфейса
Примеры:
header— шапка сайта с логотипом, навигацией и кнопкой профиляuser-profile— блок с данными пользователя, в котором используются несколько фич (например, редактирование + отображение)cart— панель корзины с количеством товара и стоимостью
Пример виджета Header
Давайте посмотрим, что происходит в следующем примере.
// src/widgets/header/ui/header.tsx
import Link from "next/link";
import { UserCard } from "@/entities/user/ui/user-card";
import { getCurrentUser } from "@/entities/user/api/get-current-user";
// Обратите внимание - это серверный компонент (нет "use client")
// Мы можем выполнять здесь серверные запросы
export const Header = async () => {
// Здесь мы получаем текущего пользователя на сервере
const user = await getCurrentUser();
return (
<header className="border-b bg-white">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
{/* Логотип и навигация */}
<div className="flex items-center gap-4">
<Link href="/" className="text-lg font-bold">
Next FSD
</Link>
<nav className="flex gap-3 text-sm text-gray-600">
<Link href="/profile">Профиль</Link>
</nav>
</div>
{/* Блок пользователя справа */}
<div>
{user ? (
// Если пользователь авторизован - показываем карточку
<UserCard user={user} />
) : (
// Если пользователь не авторизован - показываем ссылку Войти
<Link href="/login" className="text-sm text-blue-600">
Войти
</Link>
)}
</div>
</div>
</header>
);
};
Такой виджет вы можете использовать:
- в общем
layout.tsx - в конкретных страницах, если нужно
Слой pages (FSD) и app (Next.js)
Почему в FSD есть свой слой pages
Слой pages в FSD отвечает за:
- композицию виджетов и фич в конкретные страницы
- определение структуры страницы (секции, блоки)
- минимальное количество логики
При этом в Next.js маршрутизация живет в app/ или pages/ (старый подход). В nextjs-fsd подход обычно такой:
- файл маршрута (
src/app/profile/page.tsx) — «обертка» вокруг FSD-страницы - FSD-страница (
src/pages/profile/ui/profile-page.tsx) — сама реализация страницы
Это позволяет:
- изолировать бизнес-архитектуру от фреймворка
- проще переносить или переиспользовать страницу (например, в Storybook)
Пример FSD-страницы
// src/pages/profile/ui/profile-page.tsx
import { Header } from "@/widgets/header/ui/header";
import { UserProfile } from "@/widgets/user-profile/ui/user-profile";
// Здесь мы описываем структуру страницы профиля,
// используя готовые виджеты
export const ProfilePage = async () => {
return (
<div className="min-h-screen flex flex-col">
{/* Шапка сайта */}
<Header />
{/* Контент страницы */}
<main className="flex-1 container mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6">Профиль</h1>
{/* Здесь мы выводим виджет профиля пользователя */}
<UserProfile />
</main>
</div>
);
};
Пример интеграции с Next.js app router
Теперь давайте перейдем к следующему шагу — подключим эту страницу в Next.js.
// src/app/profile/page.tsx
import { ProfilePage } from "@/pages/profile/ui/profile-page";
// Этот компонент будет использован Next.js как страница по адресу /profile
// Внутри него мы просто рендерим FSD-страницу
export default function ProfileRoute() {
return <ProfilePage />;
}
Здесь маршрут Next.js:
- не содержит логики
- не знает о деталях реализации
UserProfile,Headerи т.д. - просто «связывает» Next.js с FSD-слоем
Аналогично вы можете сделать для главной страницы:
// src/pages/home/ui/home-page.tsx
import { Header } from "@/widgets/header/ui/header";
export const HomePage = async () => {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-4">Главная</h1>
<p className="text-gray-700">
Это пример главной страницы, собранной по принципам FSD.
</p>
</main>
</div>
);
};
// src/app/page.tsx
import { HomePage } from "@/pages/home/ui/home-page";
export default function HomeRoute() {
return <HomePage />;
}
Работа с серверными и клиентскими компонентами в FSD
Next.js App Router вводит разделение на:
- серверные компоненты (по умолчанию)
- клиентские компоненты (
"use client"в начале файла)
В контексте FSD это важно учитывать, чтобы не нарушать правила Next.js.
Общие рекомендации
По умолчанию делайте компоненты серверными
Если компонент не использует хуки React,useState,useEffect,useRouterи т.д. — он может быть серверным.UI-фича, использующая хуки, должна быть клиентской
В примере сLoginFormмы добавили"use client", потому что там используетсяuseState.Виджеты могут быть как серверными, так и клиентскими
Например,Headerможет быть серверным (как в примере сgetCurrentUser).Старайтесь «отталкивать» клиентский код вниз по дереву
То есть пусть страница и виджеты будут серверными, а внутри уже рендерят клиентские фичи (формы, интерактив).
Пример: серверная страница + клиентская фича
// src/pages/login/ui/login-page.tsx
import { Header } from "@/widgets/header/ui/header";
import { LoginForm } from "@/features/auth/login-by-email/ui/login-form";
export const LoginPage = () => {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 flex items-center justify-center px-4">
{/* Здесь мы выводим клиентскую фичу LoginForm */}
<LoginForm
onSuccess={() => {
// В реальном коде вы можете использовать роутер
// Но роутер можно передать внутрь через пропсы или контекст
console.log("Login success");
}}
/>
</main>
</div>
);
};
// src/app/login/page.tsx
import { LoginPage } from "@/pages/login/ui/login-page";
export default function LoginRoute() {
return <LoginPage />;
}
Здесь:
LoginPage— серверный компонент (нет"use client")LoginForm— клиентский компонент, инкапсулирующий состояние и события
Организация импортов и alias’ов
Почему алиасы важны в FSD
В FSD-архитектуре вы часто импортируете модули из разных слоев. Если не настроить alias’ы, пути будут длинными и хрупкими:
"../../../../shared/ui/button"
Гораздо удобнее использовать алиасы:
"@/shared/ui/button""@/entities/user"
Это делает структуру:
- более читаемой
- менее зависимой от реальных путей на диске
Настройка alias’ов в Next.js 13+
В Next.js на TypeScript достаточно настроить tsconfig.json (или jsconfig.json).
// tsconfig.json (фрагмент)
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@/*": ["*"],
"@shared/*": ["shared/*"],
"@entities/*": ["entities/*"],
"@features/*": ["features/*"],
"@widgets/*": ["widgets/*"],
"@pages/*": ["pages/*"]
}
}
}
Комментарии к этому фрагменту:
baseUrl: "src"— теперь все пути считаются относительно папкиsrc@/*— общий алиас, чтобы писать@/shared/ui/buttonвместо../../shared/ui/button- дополнительные алиасы можно использовать по желанию
После этого можно импортировать так:
// Было:
// import { Button } from "../../shared/ui/button";
// Стало:
import { Button } from "@/shared/ui/button";
Пример полного пути от запроса до UI в nextjs-fsd
Чтобы картина сложилась полностью, давайте разберем пример сценария:
«Показать страницу профиля, где в шапке и в блоке профиля отображаются данные текущего пользователя».
1. Сущность User
Мы уже описали типы и API в entities/user. Добавим хук для работы с пользователем на клиенте (например, при обновлении профиля).
// src/entities/user/model/hooks.ts
"use client";
import { useState, useEffect } from "react";
import { User } from "./types";
import { getCurrentUser } from "../api/get-current-user";
export function useCurrentUser() {
// Здесь мы храним пользователя
const [user, setUser] = useState<User | null>(null);
// Здесь мы храним флаг загрузки
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// При монтировании хука загружаем данные о пользователе
getCurrentUser()
.then(setUser)
.finally(() => setIsLoading(false));
}, []);
return { user, isLoading };
}
2. Виджет UserProfile
Создадим виджет, который использует этот хук и отображает профиль.
// src/widgets/user-profile/ui/user-profile.tsx
"use client";
import { useCurrentUser } from "@/entities/user/model/hooks";
import { UserCard } from "@/entities/user/ui/user-card";
export const UserProfile = () => {
const { user, isLoading } = useCurrentUser();
if (isLoading) {
// Пока идет загрузка - показываем скелетон или простой текст
return <p>Загружаем профиль...</p>;
}
if (!user) {
// Если пользователь не найден - показываем сообщение
return <p>Пользователь не авторизован.</p>;
}
// Если все хорошо - показываем карточку пользователя
return (
<section className="max-w-md">
<h2 className="text-xl font-semibold mb-4">Ваш профиль</h2>
<UserCard user={user} />
</section>
);
};
3. Страница ProfilePage
Мы уже видели пример ProfilePage, где используется Header и UserProfile. В связке все выглядит так:
// src/pages/profile/ui/profile-page.tsx
import { Header } from "@/widgets/header/ui/header";
import { UserProfile } from "@/widgets/user-profile/ui/user-profile";
export const ProfilePage = async () => {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 container mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6">Профиль</h1>
<UserProfile />
</main>
</div>
);
};
// src/app/profile/page.tsx
import { ProfilePage } from "@/pages/profile/ui/profile-page";
export default function ProfileRoute() {
return <ProfilePage />;
}
Итого:
- Next.js отвечает только за маршрут
/profile - страница собирает виджеты
- виджеты используют фичи и сущности
- бизнес-логика сосредоточена в
entitiesиfeatures
Типичные ошибки при внедрении nextjs-fsd
1. Смешивание слоев
Распространенная проблема:
- фича импортирует компонент из
widgets - сущность импортирует фичу
Это нарушает принцип «зависимости только вверх». Чтобы контролировать это, многие команды используют линтеры архитектуры (например, eslint-plugin-boundaries), но даже без них можно придерживаться простого правила:
shared— никого не знаетentities— знают толькоsharedfeatures— знаютentities,sharedwidgets— знаютfeatures,entities,sharedpages— знают все выше, кромеappapp— точка входа, знает о FSD-слоях, но старается быть тонким
2. Слишком мелкие фичи
Иногда разработчики создают фичу почти под каждый компонент. Например:
toggle-sidebaropen-modal
Такие вещи обычно лучше оставить в widgets или даже в shared. Фича должна отражать реальную пользовательскую ценность. Если действие не объясняется бизнес-языком («показать корзину», «создать заказ»), возможно, это не фича.
3. Игнорирование преимуществ серверных компонентов
При переходе на App Router есть соблазн писать «как раньше»:
- все клиентские компоненты
- все запросы через
useEffect
В FSD с Next.js лучше:
- максимально использовать серверные компоненты (
async-компоненты,fetchна сервере) - отдавать данные вниз в клиентские компоненты через пропсы
Когда FSD действительно нужен Next.js проекту
Для маленьких проектов FSD может показаться избыточным. Но он начинает «отбиваться», когда:
- страниц становится больше 5–7
- появляются повторяющиеся фичи (логин, избранное, корзина)
- над проектом работают несколько разработчиков
Признаки, что FSD поможет:
- вы не понимаете, куда положить новый компонент
- одинаковая логика дублируется на разных страницах
- изменение одной фичи вызывает правки в десятке файлов в разных местах проекта
С FSD у вас есть четкие ответы:
- бизнес-логика — в
entitiesиfeatures - композиция — в
widgetsиpages - маршруты — в
app
Заключение
Feature-Sliced Design хорошо сочетается с архитектурой Next.js, если разделить:
- область ответственности Next.js (маршруты, платформенные возможности, серверные компоненты)
- область ответственности FSD (структурирование бизнес-логики и UI по слоям и фичам)
В nextjs-fsd подходе вы:
- используете
app/для маршрутов и layout’ов - строите доменную архитектуру в
shared,entities,features,widgets,pages - подключаете FSD-страницы внутри файлов маршрутов Next.js
Такой подход позволяет:
- не привязывать бизнес-архитектуру к деталям фреймворка
- проще масштабировать проект и команду
- быстрее находить место для нового кода и понимать, где что лежит
Если вы только начинаете внедрять FSD в Next.js, имеет смысл:
- Ввести базовую структуру слоев.
- Начать переносить существующие фичи постепенно, без глобальных рефакторингов.
- Следить за направлением зависимостей между слоями.
Частозадаваемые технические вопросы по теме nextjs-fsd
Как организовать тесты в проекте с FSD и Next.js
Обычно тесты хранят рядом с кодом слоя:
entities/user/model/__tests__/hooks.test.tsfeatures/auth/login-by-email/ui/__tests__/login-form.test.tsx
Вы можете использовать Jest или Vitest. Главное правило — тестировать модули слоя изолированно: не подтягивать лишние слои в тесты. Для компонентного тестирования удобно использовать Testing Library, рендеря компоненты features и widgets без участия Next.js маршрутов.
Как подключать Storybook к FSD структуре
Storybook лучше всего использовать для слоев shared/ui, entities/ui, features/ui, widgets. Вы настраиваете Storybook так, чтобы он читал компоненты из src, с теми же alias’ами (@/shared, @/features и т.д.). Затем создаете сторис в тех же директориях, например: src/features/auth/login-by-email/ui/login-form/login-form.stories.tsx. Это помогает демонстрировать фичи и виджеты в отрыве от Next.js.
Как реализовать общие layout’ы с FSD и App Router
Layout’ы Next.js (src/app/layout.tsx, src/app/(auth)/layout.tsx) можно рассматривать как часть слоя app. Внутри layout’ов вы можете подключать виджеты из FSD, например Header или Sidebar. Логику и UI layout’а, которые относятся к домену, лучше выносить в widgets, а в layout оставлять только «каркас» и рендер детей.
Как обрабатывать глобальное состояние (например, корзину) в FSD
Глобальное состояние, завязанное на бизнес-сущности (Cart, UserSettings), обычно размещают в соответствующей сущности: entities/cart/model/store.ts. Фичи и виджеты используют это состояние через публичные API сущности. Если состояние техническое (например, тема светлая/темная), его можно вынести в shared/model или отдельный shared/lib с контекстом и хук-оберткой.
Как разделять код для public и admin части в nextjs-fsd
Если у приложения есть две зоны (public и admin), удобно добавить пространственные группировки внутри слоев. Например: features/admin/manage-users, widgets/admin/sidebar, pages/admin/dashboard. Маршруты Next.js можно разнести по группам, используя сегменты App Router (src/app/(public)/..., src/app/(admin)/...). При этом сами фичи и сущности остаются в общих слоях, но могут иметь поддиректории admin при необходимости.