All skills
ccheney avatar

/feature-slicing

@f71635f

Organize frontend code with Feature-Sliced Design (FSD). Use when adopting FSD, placing code in an existing FSD project, or fixing slice imports and public APIs; not for every new component or page.

Use this Skill: https://skilld.dev/gh/ccheney/robust-skills/feature-slicing

This session only. Nothing lands on disk.

referencesIMPLEMENTATION.md

≈4.1k tokens on demand. Your agent reads this file only when SKILL.md points to it.

FSD Implementation Patterns

Sources: Tutorial | Examples | Awesome FSD

Complete, working code patterns for a React SPA (Vite + React Router v7 + TanStack Query + Zustand + Zod 4). For Next.js-specific patterns see NEXTJS.md.

Table of Contents


Entity Pattern

Complete Entity: User

Model segment (entities/user/model/):

// entities/user/model/types.ts
export interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  role: UserRole;
  createdAt: Date;
}

export type UserRole = 'admin' | 'user' | 'guest';

export interface UserDTO {
  id: number;
  email: string;
  name: string;
  avatar_url: string | null;
  role: string;
  created_at: string;
}
// entities/user/model/mapper.ts
import type { User, UserDTO, UserRole } from './types';

export function mapUserDTO(dto: UserDTO): User {
  return {
    id: String(dto.id),
    email: dto.email,
    name: dto.name,
    avatar: dto.avatar_url ?? undefined,
    role: dto.role as UserRole,
    createdAt: new Date(dto.created_at),
  };
}
// entities/user/model/schema.ts
import { z } from 'zod';

export const userSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.email('Invalid email address'), // Zod 4: z.email(), not z.string().email()
});

export type UserFormData = z.infer<typeof userSchema>;

API segment (entities/user/api/):

// entities/user/api/userApi.ts
import { apiClient } from '@/shared/api';
import { mapUserDTO } from '../model/mapper';
import type { User, UserDTO } from '../model/types';

export async function getCurrentUser(): Promise<User> {
  const { data } = await apiClient.get<UserDTO>('/users/me');
  return mapUserDTO(data);
}

export async function getUserById(id: string): Promise<User> {
  const { data } = await apiClient.get<UserDTO>(`/users/${id}`);
  return mapUserDTO(data);
}
// entities/user/api/queries.ts
import { useQuery } from '@tanstack/react-query';
import { getCurrentUser, getUserById } from './userApi';

export const userKeys = {
  all: ['users'] as const,
  current: () => [...userKeys.all, 'current'] as const,
  detail: (id: string) => [...userKeys.all, 'detail', id] as const,
};

export function useCurrentUser() {
  return useQuery({
    queryKey: userKeys.current(),
    queryFn: getCurrentUser,
  });
}

export function useUser(id: string) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => getUserById(id),
    enabled: !!id,
  });
}

UI segment (entities/user/ui/) — note: no ui/index.ts; the slice index is the only barrel:

// entities/user/ui/UserAvatar.tsx
import type { User } from '../model/types';

interface UserAvatarProps {
  user: User;
  size?: 'sm' | 'md' | 'lg';
}

export function UserAvatar({ user, size = 'md' }: UserAvatarProps) {
  const sizes = { sm: 'w-8 h-8', md: 'w-10 h-10', lg: 'w-14 h-14' };

  if (user.avatar) {
    return (
      <img
        src={user.avatar}
        alt={user.name}
        className={`rounded-full ${sizes[size]}`}
      />
    );
  }

  return (
    <div className={`rounded-full bg-gray-200 flex items-center justify-center ${sizes[size]}`}>
      {user.name.charAt(0).toUpperCase()}
    </div>
  );
}
// entities/user/ui/UserCard.tsx
import type { User } from '../model/types';
import { UserAvatar } from './UserAvatar'; // relative import within the slice

interface UserCardProps {
  user: User;
  onClick?: () => void;
}

export function UserCard({ user, onClick }: UserCardProps) {
  return (
    <div
      onClick={onClick}
      className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 cursor-pointer"
    >
      <UserAvatar user={user} />
      <div>
        <p className="font-medium">{user.name}</p>
        <p className="text-sm text-gray-500">{user.email}</p>
      </div>
    </div>
  );
}

Public API (entities/user/index.ts):

// entities/user/index.ts
export { UserAvatar } from './ui/UserAvatar';
export { UserCard } from './ui/UserCard';
export { getCurrentUser, getUserById } from './api/userApi';
export { useCurrentUser, useUser, userKeys } from './api/queries';
export type { User, UserRole, UserDTO } from './model/types';
export { mapUserDTO } from './model/mapper';
export { userSchema, type UserFormData } from './model/schema';

Feature Pattern

Complete Feature: Authentication

Model segment (features/auth/model/):

// features/auth/model/types.ts
export interface LoginCredentials {
  email: string;
  password: string;
}

export interface RegisterData extends LoginCredentials {
  name: string;
}

export interface AuthTokens {
  accessToken: string;
  refreshToken: string;
}
// features/auth/model/store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '@/entities/user'; // feature → entity: allowed
import type { AuthTokens } from './types';

interface AuthState {
  user: User | null;
  tokens: AuthTokens | null;
  isAuthenticated: boolean;
  setAuth: (user: User, tokens: AuthTokens) => void;
  clearAuth: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      tokens: null,
      isAuthenticated: false,
      setAuth: (user, tokens) => set({ user, tokens, isAuthenticated: true }),
      clearAuth: () => set({ user: null, tokens: null, isAuthenticated: false }),
    }),
    { name: 'auth-storage' }
  )
);
// features/auth/model/schema.ts
import { z } from 'zod';

export const loginSchema = z.object({
  email: z.email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

export const registerSchema = loginSchema.extend({
  name: z.string().min(2, 'Name must be at least 2 characters'),
});

export type LoginFormData = z.infer<typeof loginSchema>;
export type RegisterFormData = z.infer<typeof registerSchema>;

API segment (features/auth/api/):

// features/auth/api/authApi.ts
import { apiClient } from '@/shared/api';
import { mapUserDTO, type User, type UserDTO } from '@/entities/user';
import type { LoginCredentials, RegisterData, AuthTokens } from '../model/types';

interface AuthResponse {
  user: UserDTO;
  access_token: string;
  refresh_token: string;
}

export async function login(credentials: LoginCredentials): Promise<{ user: User; tokens: AuthTokens }> {
  const { data } = await apiClient.post<AuthResponse>('/auth/login', credentials);
  return {
    user: mapUserDTO(data.user),
    tokens: { accessToken: data.access_token, refreshToken: data.refresh_token },
  };
}

export async function register(data: RegisterData): Promise<{ user: User; tokens: AuthTokens }> {
  const { data: response } = await apiClient.post<AuthResponse>('/auth/register', data);
  return {
    user: mapUserDTO(response.user),
    tokens: { accessToken: response.access_token, refreshToken: response.refresh_token },
  };
}

export async function logout(): Promise<void> {
  await apiClient.post('/auth/logout');
}

UI segment (features/auth/ui/):

// features/auth/ui/LoginForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/shared/ui/button';
import { Input } from '@/shared/ui/input';
import { loginSchema, type LoginFormData } from '../model/schema';
import { login } from '../api/authApi';
import { useAuthStore } from '../model/store';

export function LoginForm() {
  const setAuth = useAuthStore((s) => s.setAuth);
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = async (data: LoginFormData) => {
    const { user, tokens } = await login(data);
    setAuth(user, tokens);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <Input
        {...register('email')}
        type="email"
        placeholder="Email"
        error={errors.email?.message}
      />
      <Input
        {...register('password')}
        type="password"
        placeholder="Password"
        error={errors.password?.message}
      />
      <Button type="submit" loading={isSubmitting}>
        Sign In
      </Button>
    </form>
  );
}
// features/auth/ui/LogoutButton.tsx
import { Button } from '@/shared/ui/button';
import { logout } from '../api/authApi';
import { useAuthStore } from '../model/store';

export function LogoutButton() {
  const clearAuth = useAuthStore((s) => s.clearAuth);

  const handleLogout = async () => {
    await logout();
    clearAuth();
  };

  return (
    <Button variant="ghost" onClick={handleLogout}>
      Sign Out
    </Button>
  );
}

Public API (features/auth/index.ts):

// features/auth/index.ts
export { LoginForm } from './ui/LoginForm';
export { LogoutButton } from './ui/LogoutButton';
export { useAuthStore } from './model/store';
export { login, register, logout } from './api/authApi';
export type { LoginCredentials, AuthTokens } from './model/types';
export { loginSchema, registerSchema } from './model/schema';

Widget Pattern

Header Widget

Widgets compose entities and features — and since v2.1 they may own their own data fetching (e.g. a notification count query in widgets/header/api/).

// widgets/header/ui/Header.tsx
import { Link } from 'react-router';
import { UserAvatar } from '@/entities/user';
import { LogoutButton, useAuthStore } from '@/features/auth';
import { SearchBox } from '@/features/search-products';
import { Logo } from '@/shared/ui/logo';

export function Header() {
  const { user, isAuthenticated } = useAuthStore();

  return (
    <header className="flex items-center justify-between px-6 py-4 border-b">
      <Link to="/">
        <Logo />
      </Link>
      <SearchBox />
      <nav className="flex items-center gap-4">
        {isAuthenticated ? (
          <>
            <UserAvatar user={user!} size="sm" />
            <LogoutButton />
          </>
        ) : (
          <Link to="/login">Sign In</Link>
        )}
      </nav>
    </header>
  );
}
// widgets/header/index.ts
export { Header } from './ui/Header';

Page Pattern

Product Detail Page

Page-local blocks (hero sections, forms used only here) stay in the page's ui/ — don't extract them until a second page needs them.

// pages/product-detail/api/loader.ts
import { getProductById } from '@/entities/product';
import type { LoaderFunctionArgs } from 'react-router';

export async function productDetailLoader({ params }: LoaderFunctionArgs) {
  const product = await getProductById(params.id!);
  return { product };
}
// pages/product-detail/ui/ProductDetailPage.tsx
import { useLoaderData } from 'react-router';
import { ProductCard, type Product } from '@/entities/product';
import { AddToCartButton } from '@/features/add-to-cart';
import { Header } from '@/widgets/header';

export function ProductDetailPage() {
  const { product } = useLoaderData() as { product: Product };

  return (
    <>
      <Header />
      <main className="max-w-4xl mx-auto py-8">
        <ProductCard product={product} />
        <AddToCartButton productId={product.id} />
      </main>
    </>
  );
}
// pages/product-detail/index.ts
export { ProductDetailPage } from './ui/ProductDetailPage';
export { productDetailLoader } from './api/loader';

Shared Layer Pattern

API Client

// shared/api/client.ts
import axios from 'axios';

export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  headers: { 'Content-Type': 'application/json' },
});

apiClient.interceptors.request.use((config) => {
  const storage = localStorage.getItem('auth-storage');
  if (storage) {
    const { state } = JSON.parse(storage);
    if (state?.tokens?.accessToken) {
      config.headers.Authorization = `Bearer ${state.tokens.accessToken}`;
    }
  }
  return config;
});

apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('auth-storage');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);
// shared/api/index.ts — public API of the segment
export { apiClient } from './client';

UI Kit — one folder + index per component

No root shared/ui/index.ts. Each component is imported directly (@/shared/ui/button) so unrelated components never end up in the same module graph.

shared/ui/
├── button/
│   ├── Button.tsx
│   └── index.ts
└── input/
    ├── Input.tsx
    └── index.ts
// shared/ui/button/Button.tsx
import type { ButtonHTMLAttributes } from 'react';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'ghost';
  loading?: boolean;
}

// React 19: ref is a regular prop — no forwardRef needed.
// On React ≤18, wrap with forwardRef instead.
export function Button({ variant = 'primary', loading, children, disabled, ...props }: ButtonProps) {
  const variants = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
    ghost: 'text-gray-600 hover:bg-gray-100',
  };

  return (
    <button
      disabled={disabled || loading}
      className={`px-4 py-2 rounded-lg font-medium ${variants[variant]} disabled:opacity-50`}
      {...props}
    >
      {loading ? 'Loading…' : children}
    </button>
  );
}
// shared/ui/input/Input.tsx
import type { InputHTMLAttributes } from 'react';

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  error?: string;
}

export function Input({ error, className, ...props }: InputProps) {
  return (
    <div>
      <input
        className={`w-full px-3 py-2 border rounded-lg ${
          error ? 'border-red-500' : 'border-gray-300'
        } ${className ?? ''}`}
        {...props}
      />
      {error && <p className="mt-1 text-sm text-red-500">{error}</p>}
    </div>
  );
}
// shared/ui/button/index.ts
export { Button } from './Button';

// shared/ui/input/index.ts
export { Input } from './Input';

App Layer Pattern

Providers Setup

// app/providers/index.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './ThemeProvider';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 1000 * 60 * 5, retry: 1 },
  },
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>{children}</ThemeProvider>
    </QueryClientProvider>
  );
}

Router Configuration (React Router v7)

// app/routes/router.tsx
import { createBrowserRouter } from 'react-router';
import { HomePage } from '@/pages/home';
import { ProductDetailPage, productDetailLoader } from '@/pages/product-detail';
import { LoginPage } from '@/pages/login';

export const router = createBrowserRouter([
  { path: '/', element: <HomePage /> },
  {
    path: '/products/:id',
    element: <ProductDetailPage />,
    loader: productDetailLoader,
  },
  { path: '/login', element: <LoginPage /> },
]);

React Router v7 merged react-router-dom into react-router — on v6, import from react-router-dom instead.


TypeScript Configuration

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Source: SKILL.md on GitHub

1 warning15d5 checks · Risk SAFE
  • Gen Agent Trust Hub15d

    The skill provides comprehensive instructions and reference material for implementing Feature-Sliced Design (FSD) in frontend projects. It includes guidelines for layer organization, import rules, public APIs, and Next.js integration. The skill references official FSD documentation and tooling, such as the Steiger linter, and follows standard React/Next.js development patterns.

  • Socket15d

    No alerts

  • Snyk15d

    Risk: LOW · No issues

  • Runlayer7mo

    4/7 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at f71635f. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 weeks ago.

Activeupdated 3 weeks ago

README badge

README badge for ccheney/robust-skills/feature-slicing