Home Curriculum Sobre mí Blog Hablemos →

Feature-Sliced Design en frontend: arquitectura para escalar React sin convertirlo en una bola de barro

Tutorial paso a paso para aplicar Feature-Sliced Design en una app React con Vite: capas, slices, public API, reglas de importación, entidades, features, widgets y criterios para escalar sin perder claridad.

Rafael Lozano

Vertical slicing te ayuda a ordenar el frontend por funcionalidades. Eso ya es un salto enorme frente al clásico components, hooks, services, utils y types.

Pero cuando la app crece, aparece otro problema: no alcanza con decir “todo lo del login vive en features/auth”. Empiezas a tener features que comparten entidades, páginas que mezclan lógica de negocio, componentes que importan de cualquier lado y un shared que se convierte en basurero técnico.

Ahí entra Feature-Sliced Design.

Feature-Sliced Design, o FSD, es una metodología para organizar frontend por capas, slices y reglas de dependencia. No solo te dice dónde poner archivos. Te obliga a pensar qué representa cada parte del sistema y quién puede depender de quién.

Este post es un tutorial paso a paso para aplicar Feature-Sliced Design en una app con Vite, React y TypeScript.


Qué es Feature-Sliced Design

Feature-Sliced Design es una arquitectura frontend que organiza el código en capas con responsabilidades claras.

La estructura más conocida se ve así:

src/
├── app/
├── pages/
├── widgets/
├── features/
├── entities/
└── shared/
Infografía de Feature-Sliced Design: seis capas apiladas de arriba abajo — app, pages, widgets, features, entities y shared — con dependencias unidireccionales hacia capas inferiores. Resume beneficios como escalabilidad, límites claros entre capas y reglas de importación predecibles.

Cada capa responde una pregunta distinta.

app contiene la inicialización global de la aplicación: providers, router, estilos globales, configuración.

pages contiene pantallas completas y rutas.

widgets contiene bloques grandes de UI que combinan varias piezas: header, sidebar, checkout summary, product grid.

features contiene acciones de usuario: iniciar sesión, agregar al carrito, aplicar cupón, cambiar contraseña.

entities contiene conceptos del negocio: usuario, producto, carrito, pedido.

shared contiene infraestructura y piezas reutilizables sin conocimiento de negocio: cliente HTTP, componentes base, helpers genéricos.

La diferencia con una estructura común no es el nombre de las carpetas. La diferencia es la regla mental: cada capa tiene una responsabilidad y solo puede depender de capas más bajas.


Por qué importa en frontend

Frontend moderno no es solo pintar componentes.

Una app real tiene estado remoto, reglas de negocio, permisos, formularios, errores, entidades compartidas, rutas, composición visual y coordinación entre muchas piezas. Si no pones límites, React te deja mezclar todo con todo.

Y React no te va a salvar de una mala arquitectura.

FSD importa porque reduce tres problemas muy comunes:

  • No saber dónde poner código nuevo.
  • No saber desde dónde importar algo.
  • No saber qué se rompe cuando cambias una funcionalidad.

En una app chica, eso parece exagerado. En una app de producto con meses de evolución, es la diferencia entre avanzar con confianza o hacer arqueología cada vez que abres un PR.

La arquitectura buena no existe para lucirse. Existe para bajar el costo de cambio.


Paso 1: crear el proyecto con Vite y React

Arranquemos desde cero.

npm create vite@latest fsd-demo -- --template react-ts
cd fsd-demo
npm install
npm run dev

Vite te da una base limpia. Eso ayuda porque puedes ver la arquitectura sin pelearte con demasiada configuración inicial.

La estructura inicial va a ser algo así:

src/
├── App.tsx
├── main.tsx
├── assets/
├── index.css
└── vite-env.d.ts

Esta estructura sirve para empezar. No sirve como diseño de aplicación.


Paso 2: definir las capas base

Vamos a llevar la app hacia esta estructura:

src/
├── app/
│   ├── App.tsx
│   └── router.tsx
├── pages/
│   ├── login/
│   └── products/
├── widgets/
│   └── product-catalog/
├── features/
│   ├── login-by-email/
│   └── add-product-to-cart/
├── entities/
│   ├── user/
│   ├── product/
│   └── cart/
├── shared/
│   ├── api/
│   ├── ui/
│   └── lib/
└── main.tsx

La idea no es crear carpetas por crear carpetas. Eso sería teatro arquitectónico.

La idea es separar por intención:

  • product es una entidad del negocio.
  • add-product-to-cart es una acción de usuario.
  • product-catalog es un bloque visual compuesto.
  • products es una página.
  • api y ui dentro de shared no saben nada del negocio.

Cuando entiendes esa diferencia, FSD empieza a tener sentido.


Paso 3: mover el root de la app

Primero deja main.tsx como punto de entrada mínimo.

// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import './index.css';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

Después crea src/app/App.tsx.

// src/app/App.tsx
import { ProductsPage } from '../pages/products/ProductsPage';

export function App() {
  return <ProductsPage />;
}

app no es una carpeta para meter cualquier cosa global. Es el punto de composición global. Si metes lógica de producto ahí, estás rompiendo el límite desde el primer día.


Paso 4: crear shared sin convertirlo en basurero

shared es la capa más baja. Todo puede depender de shared, pero shared no debería depender del negocio.

Un cliente HTTP base sí puede vivir ahí:

// src/shared/api/httpClient.ts
export async function httpClient<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
  const response = await fetch(input, init);

  if (!response.ok) {
    throw new Error(`Request failed with status ${response.status}`);
  }

  return response.json();
}

Un botón genérico también:

// src/shared/ui/Button.tsx
import type { ButtonHTMLAttributes, ReactNode } from 'react';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  children: ReactNode;
}

export function Button({ children, ...props }: ButtonProps) {
  return <button {...props}>{children}</button>;
}

Pero un AddToCartButton no va en shared/ui.

Ese botón tiene intención de negocio. Pertenece a una feature.

Una pregunta práctica: si borro todo el dominio de productos, este archivo sigue teniendo sentido?

Si la respuesta es no, no va en shared.


Paso 5: crear una entidad: product

En FSD, una entidad representa un concepto del negocio.

Vamos a crear product.

src/entities/product/
├── api/
│   └── getProducts.ts
├── model/
│   └── types.ts
├── ui/
│   └── ProductCard.tsx
└── index.ts

Primero los tipos:

// src/entities/product/model/types.ts
export interface Product {
  id: string;
  name: string;
  price: number;
  imageUrl: string;
}

Después la llamada de API relacionada con la entidad:

// src/entities/product/api/getProducts.ts
import { httpClient } from '../../../shared/api/httpClient';
import type { Product } from '../model/types';

export function getProducts() {
  return httpClient<Product[]>('/api/products');
}

Y una UI básica de entidad:

// src/entities/product/ui/ProductCard.tsx
import type { Product } from '../model/types';

interface ProductCardProps {
  product: Product;
  action?: React.ReactNode;
}

export function ProductCard({ product, action }: ProductCardProps) {
  return (
    <article>
      <img src={product.imageUrl} alt={product.name} />
      <h2>{product.name}</h2>
      <p>${product.price}</p>
      {action}
    </article>
  );
}

Por último, define la public API:

// src/entities/product/index.ts
export { getProducts } from './api/getProducts';
export { ProductCard } from './ui/ProductCard';
export type { Product } from './model/types';

Este index.ts es importante. El resto de la app debería importar desde entities/product, no desde archivos internos.

Mal:

import { ProductCard } from '../../entities/product/ui/ProductCard';

Bien:

import { ProductCard } from '../../entities/product';

La public API protege el interior del slice.


Paso 6: crear una feature: add-product-to-cart

Una feature no es una entidad. Esto es clave.

product es una cosa del negocio. add-product-to-cart es una acción que el usuario puede ejecutar.

Vamos a crearla:

src/features/add-product-to-cart/
├── api/
│   └── addProductToCart.ts
├── ui/
│   └── AddProductToCartButton.tsx
└── index.ts

Primero la API:

// src/features/add-product-to-cart/api/addProductToCart.ts
import { httpClient } from '../../../shared/api/httpClient';

export function addProductToCart(productId: string) {
  return httpClient('/api/cart/items', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ productId }),
  });
}

Después la UI de la feature:

// src/features/add-product-to-cart/ui/AddProductToCartButton.tsx
import { useState } from 'react';
import { Button } from '../../../shared/ui/Button';
import { addProductToCart } from '../api/addProductToCart';

interface AddProductToCartButtonProps {
  productId: string;
}

export function AddProductToCartButton({ productId }: AddProductToCartButtonProps) {
  const [isLoading, setIsLoading] = useState(false);

  async function handleClick() {
    setIsLoading(true);

    try {
      await addProductToCart(productId);
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <Button type="button" onClick={handleClick} disabled={isLoading}>
      {isLoading ? 'Adding...' : 'Add to cart'}
    </Button>
  );
}

Y su public API:

// src/features/add-product-to-cart/index.ts
export { AddProductToCartButton } from './ui/AddProductToCartButton';

Fíjate en algo importante: la feature puede importar desde shared. También puede recibir datos de una entidad mediante props. Pero evita que una entidad dependa de una feature.

ProductCard no debería importar AddProductToCartButton directamente, porque entonces la entidad product quedaría acoplada a una acción concreta.

La composición va a ocurrir más arriba.


Paso 7: crear un widget que componga entidades y features

Un widget es un bloque grande de interfaz. No es tan genérico como shared/ui, pero tampoco es una acción específica como una feature.

Vamos a crear un catálogo de productos.

src/widgets/product-catalog/
├── ui/
│   └── ProductCatalog.tsx
└── index.ts

El widget puede usar entidades y features:

// src/widgets/product-catalog/ui/ProductCatalog.tsx
import { useEffect, useState } from 'react';
import { ProductCard, getProducts, type Product } from '../../../entities/product';
import { AddProductToCartButton } from '../../../features/add-product-to-cart';

export function ProductCatalog() {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    let ignore = false;

    async function loadProducts() {
      const data = await getProducts();

      if (!ignore) {
        setProducts(data);
        setIsLoading(false);
      }
    }

    loadProducts();

    return () => {
      ignore = true;
    };
  }, []);

  if (isLoading) {
    return <p>Loading products...</p>;
  }

  return (
    <section>
      {products.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          action={<AddProductToCartButton productId={product.id} />}
        />
      ))}
    </section>
  );
}

Public API:

// src/widgets/product-catalog/index.ts
export { ProductCatalog } from './ui/ProductCatalog';

Este ejemplo muestra por qué widgets existe.

La entidad product no sabe nada del carrito. La feature add-product-to-cart no sabe nada del layout del catálogo. El widget junta ambas cosas para construir una experiencia visible.

Eso es composición con límites.


Paso 8: crear una página que use el widget

La página debería ser una composición de alto nivel.

// src/pages/products/ProductsPage.tsx
import { ProductCatalog } from '../../widgets/product-catalog';

export function ProductsPage() {
  return (
    <main>
      <h1>Products</h1>
      <p>Browse the catalog and add products to your cart.</p>
      <ProductCatalog />
    </main>
  );
}

La página no debería saber cómo se cargan los productos ni cómo se agrega algo al carrito. Su trabajo es componer la pantalla.

Si una página empieza a tener demasiado estado, demasiadas llamadas de API y demasiada lógica, normalmente te está avisando que falta extraer un widget o una feature.


Paso 9: agregar login como otra feature

Ahora hagamos un segundo flujo para ver el patrón.

En vez de crear una feature gigante llamada auth, podemos crear una acción más concreta: login-by-email.

src/features/login-by-email/
├── api/
│   └── loginByEmail.ts
├── model/
│   └── types.ts
├── ui/
│   └── LoginByEmailForm.tsx
└── index.ts

Tipos:

// src/features/login-by-email/model/types.ts
export interface LoginByEmailCredentials {
  email: string;
  password: string;
}

API:

// src/features/login-by-email/api/loginByEmail.ts
import { httpClient } from '../../../shared/api/httpClient';
import type { LoginByEmailCredentials } from '../model/types';

export function loginByEmail(credentials: LoginByEmailCredentials) {
  return httpClient('/api/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(credentials),
  });
}

UI:

// src/features/login-by-email/ui/LoginByEmailForm.tsx
import { FormEvent, useState } from 'react';
import { Button } from '../../../shared/ui/Button';
import { loginByEmail } from '../api/loginByEmail';

export function LoginByEmailForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setError(null);

    try {
      await loginByEmail({ email, password });
    } catch (error) {
      setError(error instanceof Error ? error.message : 'Unexpected error');
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" value={email} onChange={(event) => setEmail(event.target.value)} />

      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(event) => setPassword(event.target.value)}
      />

      {error && <p role="alert">{error}</p>}

      <Button type="submit">Sign in</Button>
    </form>
  );
}

Public API:

// src/features/login-by-email/index.ts
export { LoginByEmailForm } from './ui/LoginByEmailForm';

Página:

// src/pages/login/LoginPage.tsx
import { LoginByEmailForm } from '../../features/login-by-email';

export function LoginPage() {
  return (
    <main>
      <h1>Sign in</h1>
      <LoginByEmailForm />
    </main>
  );
}

Fíjate en el naming. login-by-email es más explícito que auth. Te dice qué acción resuelve. Si mañana agregas login-with-google, no tienes que meterlo todo en una carpeta auth cada vez más grande.


Paso 10: agregar rutas sin romper las capas

Instala React Router si lo necesitas.

npm install react-router-dom

Crea el router en app.

// src/app/router.tsx
import { createBrowserRouter } from 'react-router-dom';
import { LoginPage } from '../pages/login/LoginPage';
import { ProductsPage } from '../pages/products/ProductsPage';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <ProductsPage />,
  },
  {
    path: '/login',
    element: <LoginPage />,
  },
]);

Actualiza App.

// src/app/App.tsx
import { RouterProvider } from 'react-router-dom';
import { router } from './router';

export function App() {
  return <RouterProvider router={router} />;
}

El router vive en app porque es configuración global de la aplicación. Las páginas viven en pages. Las páginas componen widgets y features. Las features usan entidades y shared.

Ese flujo importa más que memorizar una carpeta.


Reglas prácticas para no arruinarlo

FSD funciona si respetas límites. Si solo copias carpetas, no estás haciendo arquitectura. Estás decorando el caos.

Estas reglas ayudan mucho.

1. Importa desde la public API del slice.

No hagas esto:

import { ProductCard } from '../../entities/product/ui/ProductCard';

Haz esto:

import { ProductCard } from '../../entities/product';

2. Respeta la dirección de dependencias.

Una regla práctica:

app → pages → widgets → features → entities → shared

Una capa puede importar de capas inferiores. No debería importar de capas superiores.

3. No metas dominio en shared.

shared no es para lo que “se usa en dos lugares”. Es para lo que no conoce el negocio.

4. Nombra features como acciones.

add-product-to-cart, login-by-email, apply-coupon, change-password. Eso comunica intención mejor que carpetas genéricas.

5. Nombra entities como conceptos del negocio.

user, product, cart, order, invoice. Si suena a sustantivo del dominio, probablemente sea entity.

6. No empieces con FSD completo si no tienes presión real.

Para una landing page, esto es demasiado. Para una app con múltiples flujos, equipos y evolución constante, empieza a pagar.


Feature-Sliced Design no es solo vertical slicing

Se parecen, pero no son lo mismo.

Vertical slicing es una idea general: agrupar código por funcionalidades completas.

FSD es una metodología más formal: define capas, slices, segmentos, public APIs y reglas de importación.

Puedes pensar en vertical slicing como el primer paso: dejas de organizar por tipo técnico y empiezas a organizar por motivo de cambio.

FSD toma esa idea y le agrega una estructura más estricta para que el proyecto no se vuelva ambiguo cuando crece.

No necesitas FSD para todo. Pero cuando tu app ya tiene entidades compartidas, features que se combinan, páginas pesadas y PRs difíciles de revisar, FSD empieza a ser una herramienta muy útil.


Cuándo sí y cuándo no usarlo

Usa Feature-Sliced Design cuando tu frontend tiene producto real detrás: dashboard, checkout, administración, billing, reportes, usuarios, roles, permisos, catálogo, formularios complejos.

También úsalo cuando el equipo necesita reglas claras para decidir dónde va cada cosa.

No lo uses si estás haciendo una landing simple, un prototipo de dos días o una app donde todavía no entiendes el dominio. Ahí FSD puede volverse peso muerto.

La arquitectura no debería llegar antes que el problema. Debería llegar cuando el problema empieza a repetirse.


Resultado final

Después de aplicar el tutorial, tu app queda organizada así:

src/
├── app/
│   ├── App.tsx
│   └── router.tsx
├── pages/
│   ├── login/
│   │   └── LoginPage.tsx
│   └── products/
│       └── ProductsPage.tsx
├── widgets/
│   └── product-catalog/
│       ├── ui/
│       │   └── ProductCatalog.tsx
│       └── index.ts
├── features/
│   ├── add-product-to-cart/
│   │   ├── api/
│   │   ├── ui/
│   │   └── index.ts
│   └── login-by-email/
│       ├── api/
│       ├── model/
│       ├── ui/
│       └── index.ts
├── entities/
│   └── product/
│       ├── api/
│       ├── model/
│       ├── ui/
│       └── index.ts
├── shared/
│   ├── api/
│   │   └── httpClient.ts
│   └── ui/
│       └── Button.tsx
└── main.tsx

No es una estructura mágica. Es un contrato.

El equipo sabe dónde vive una entidad, dónde vive una acción, dónde se compone una pantalla y qué cosas pueden ser compartidas sin contaminarse de negocio.

Eso baja discusiones. Baja imports raros. Baja miedo al cambio.


Cierre

Feature-Sliced Design no te vuelve mejor frontend por copiar seis carpetas. Te vuelve mejor frontend si entiendes el motivo detrás de esas carpetas.

entities no existe para sonar sofisticado. Existe para proteger conceptos del negocio.

features no existe para guardar cualquier componente. Existe para modelar acciones de usuario.

widgets no existe para complicar la UI. Existe para componer piezas sin ensuciar páginas.

Y shared no existe para esconder decisiones difíciles. Existe para código realmente genérico.

Si aplicas FSD con criterio, tu frontend deja de ser una colección de componentes y empieza a parecerse a un producto diseñado con límites.

Ese es el punto: no más carpetas decorativas. Arquitectura con intención.