Vertical slicing en frontend: organiza React por funcionalidades, no por carpetas
Tutorial paso a paso para aplicar vertical slicing en una app React con Vite: estructura, rutas, feature slices, API, estado, UI y criterios para escalar sin perder claridad.
La mayoría de proyectos React empiezan igual: components, hooks, services, utils, types. Parece ordenado. Parece profesional. Parece que alguien pensó la arquitectura.
Hasta que la app crece.
Un día quieres tocar el flujo de login y terminas abriendo seis carpetas distintas. El formulario está en components, el hook en hooks, la llamada HTTP en services, los tipos en types, la validación en utils y la página en pages. Todo está separado por tipo técnico, pero nada está junto por motivo de cambio.
Ahí aparece el problema real: tu estructura no está organizada como trabaja el producto.
Vertical slicing propone otra forma de pensar el frontend: en vez de separar primero por tipo de archivo, separas por funcionalidad. Cada feature agrupa su UI, estado, llamadas de API, tipos y lógica cercana. Si cambia una funcionalidad, sabes dónde mirar. Si borras una funcionalidad, sabes qué carpeta borrar. Si entra alguien nuevo, entiende el sistema desde el dominio, no desde una taxonomía genérica.
Este post es un tutorial paso a paso para aplicar vertical slicing en una app con Vite, React y TypeScript.
Qué es vertical slicing

Vertical slicing es organizar el código por cortes funcionales completos.
Un slice vertical contiene todo lo que necesita una funcionalidad para existir:
- UI.
- Estado local o remoto.
- Hooks.
- Tipos.
- Validaciones.
- Adaptadores de API.
- Tests, si aplican.
En vez de esto:
src/
├── components/
│ └── LoginForm.tsx
├── hooks/
│ └── useLogin.ts
├── services/
│ └── authService.ts
├── types/
│ └── auth.ts
└── pages/
└── LoginPage.tsx
Vas hacia esto:
src/
├── features/
│ └── auth/
│ ├── api/
│ │ └── login.ts
│ ├── model/
│ │ └── types.ts
│ ├── ui/
│ │ └── LoginForm.tsx
│ └── index.ts
└── pages/
└── login/
└── LoginPage.tsx
La diferencia no es estética. Es arquitectónica.
En la primera estructura, el login está repartido por toda la app. En la segunda, el login tiene un lugar propio. Eso cambia cómo lees, cambias, testeas y eliminas código.
Por qué importa en frontend
Frontend no es solo componentes. Eso hay que sacárselo de la cabeza cuanto antes.
Una funcionalidad real mezcla UI, reglas de negocio, datos remotos, estado, formularios, permisos, navegación y manejo de errores. Si organizas todo por tipo técnico, obligas al equipo a reconstruir mentalmente la feature cada vez que la toca.
Vertical slicing reduce ese costo cognitivo.
Cuando una persona abre features/cart, entiende que ahí vive el carrito. Cuando abre features/search, entiende que ahí vive la búsqueda. No tiene que perseguir migas entre hooks, utils, services y components.
El beneficio se nota especialmente cuando:
- Tu app tiene más de cinco flujos importantes.
- Hay más de dos personas tocando el mismo frontend.
- Los PRs mezclan lógica, UI y API.
- Te cuesta borrar features viejas.
- Cada dev contesta distinto cuando alguien pregunta “¿dónde pongo esto?”.
Si eso te suena familiar, vertical slicing no es teoría linda. Es mantenimiento preventivo.
Paso 1: crear el proyecto con Vite y React
Arranquemos desde cero.
npm create vite@latest vertical-slicing-demo -- --template react-ts
cd vertical-slicing-demo
npm install
npm run dev
Vite te da una base chica, rápida y sin demasiada ceremonia. Perfecto para explicar arquitectura sin esconder el problema debajo de un framework gigante.
La estructura inicial se va a ver más o menos así:
src/
├── App.tsx
├── main.tsx
├── assets/
├── index.css
└── vite-env.d.ts
No vamos a dejarla así. Esta estructura sirve para arrancar, no para escalar.
Paso 2: definir una estructura base
Para una app mediana, una estructura simple y práctica puede ser esta:
src/
├── app/
│ ├── App.tsx
│ └── router.tsx
├── features/
│ ├── auth/
│ └── products/
├── pages/
│ ├── login/
│ └── products/
├── shared/
│ ├── api/
│ ├── ui/
│ └── lib/
└── main.tsx
Cada carpeta tiene una responsabilidad clara.
app contiene el arranque global: providers, router, configuración de la aplicación.
pages contiene pantallas completas. Una página compone features, pero no debería concentrar toda la lógica.
features contiene funcionalidades de usuario: login, búsqueda, carrito, favoritos, filtros, checkout.
shared contiene piezas sin conocimiento de negocio: componentes base, cliente HTTP, helpers genéricos.
La regla importante es esta: si algo pertenece a una funcionalidad concreta, no lo mandes a shared solo porque “podría reutilizarse” algún día.
Ese “algún día” es una fábrica de abstracciones malas.
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 { LoginPage } from '../pages/login/LoginPage';
export function App() {
return <LoginPage />;
}
Todavía no necesitamos router. No metas herramientas antes de necesitar el problema que resuelven. Primero ordena el corte vertical. Después agregas rutas.
Paso 4: crear el primer slice: auth
Vamos a crear una feature de login.
src/features/auth/
├── api/
│ └── login.ts
├── model/
│ └── types.ts
├── ui/
│ └── LoginForm.tsx
└── index.ts
Empieza por los tipos de la feature.
// src/features/auth/model/types.ts
export interface LoginCredentials {
email: string;
password: string;
}
export interface AuthUser {
id: string;
name: string;
email: string;
}
Ahora la llamada de API.
// src/features/auth/api/login.ts
import type { AuthUser, LoginCredentials } from '../model/types';
export async function login(credentials: LoginCredentials): Promise<AuthUser> {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error('Invalid email or password');
}
return response.json();
}
Y ahora la UI que usa esa función.
// src/features/auth/ui/LoginForm.tsx
import { FormEvent, useState } from 'react';
import { login } from '../api/login';
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
const user = await login({ email, password });
console.log('Logged in user:', user);
} catch (error) {
setError(error instanceof Error ? error.message : 'Unexpected error');
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="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" disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
);
}
Por último, expón una API pública del slice.
// src/features/auth/index.ts
export { LoginForm } from './ui/LoginForm';
export type { AuthUser, LoginCredentials } from './model/types';
Este archivo parece menor, pero es clave. El resto de la app debería importar desde features/auth, no desde archivos internos como features/auth/ui/LoginForm.
La public API te permite cambiar el interior del slice sin romper consumidores.
Paso 5: crear una página que componga la feature
La página no debería saber demasiado. Su trabajo es componer.
// src/pages/login/LoginPage.tsx
import { LoginForm } from '../../features/auth';
export function LoginPage() {
return (
<main>
<section>
<h1>Sign in</h1>
<p>Access your account to continue.</p>
<LoginForm />
</section>
</main>
);
}
Fíjate en el límite: la página no sabe cómo se envía el formulario, no sabe qué endpoint se llama, no sabe cómo se construyen las credenciales. Solo sabe que existe un LoginForm.
Ese límite es sano.
Paso 6: agregar shared sin convertirlo en basurero
shared no significa “cosas que no sé dónde poner”.
shared significa código estable, genérico y sin contexto de negocio.
Por ejemplo, puedes tener un cliente HTTP base:
// 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();
}
Y usarlo desde la feature:
// src/features/auth/api/login.ts
import { httpClient } from '../../../shared/api/httpClient';
import type { AuthUser, LoginCredentials } from '../model/types';
export function login(credentials: LoginCredentials) {
return httpClient<AuthUser>('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
}
Pero no muevas LoginCredentials a shared/types solo porque está escrito en TypeScript. Ese tipo pertenece al login. Déjalo cerca del login.
Una buena pregunta para decidir si algo va en shared es esta: ¿este archivo tiene sentido si borro toda la feature actual?
Si la respuesta es no, no va en shared.
Paso 7: crear otro slice para ver el patrón
Una arquitectura no se entiende con un solo ejemplo. Agreguemos productos.
src/features/products/
├── api/
│ └── getProducts.ts
├── model/
│ └── types.ts
├── ui/
│ └── ProductList.tsx
└── index.ts
Tipos:
// src/features/products/model/types.ts
export interface Product {
id: string;
name: string;
price: number;
}
API:
// src/features/products/api/getProducts.ts
import { httpClient } from '../../../shared/api/httpClient';
import type { Product } from '../model/types';
export function getProducts() {
return httpClient<Product[]>('/api/products');
}
UI:
// src/features/products/ui/ProductList.tsx
import { useEffect, useState } from 'react';
import { getProducts } from '../api/getProducts';
import type { Product } from '../model/types';
export function ProductList() {
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 (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
);
}
Public API:
// src/features/products/index.ts
export { ProductList } from './ui/ProductList';
export type { Product } from './model/types';
Página:
// src/pages/products/ProductsPage.tsx
import { ProductList } from '../../features/products';
export function ProductsPage() {
return (
<main>
<h1>Products</h1>
<ProductList />
</main>
);
}
Ahora tienes dos funcionalidades completas con la misma forma. Eso es lo que buscas: no una arquitectura perfecta, sino una arquitectura que el equipo pueda repetir sin discutir cada archivo.
Paso 8: agregar rutas sin romper el corte vertical
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 compone páginas. Las páginas componen features. Las features encapsulan implementación.
Ese flujo es más importante que la carpeta exacta.
Reglas prácticas para no arruinarlo
Vertical slicing funciona si tienes límites. Si no, se transforma en otra estructura linda que se pudre en tres meses.
Estas reglas ayudan mucho.
1. Importa desde la public API del slice. No hagas esto desde una página:
import { LoginForm } from '../../features/auth/ui/LoginForm';
Haz esto:
import { LoginForm } from '../../features/auth';
2. No mandes todo a shared.
shared debe crecer lento. Si crece demasiado rápido, probablemente estás evitando tomar decisiones de diseño.
3. Duplica antes de abstraer mal.
Dos formularios parecidos no justifican automáticamente un UniversalFormBuilder. Espera a ver el patrón real.
4. Mantén las features chicas.
Si features/products empieza a tener veinte casos de uso, quizás no tienes una feature. Tienes un módulo de dominio que necesita dividirse: product-search, product-filters, product-details, product-reviews.
5. Separa dominio de componentes genéricos.
Un Button va en shared/ui. Un AddToCartButton va en features/cart porque tiene intención de negocio.
Vertical slicing no es Feature-Sliced Design
Se parecen, pero no son lo mismo.
Vertical slicing es una idea arquitectónica general: organizar por funcionalidades completas.
Feature-Sliced Design es una metodología más formal, con capas como app, pages, widgets, features, entities y shared, además de reglas de importación más estrictas.
Puedes aplicar vertical slicing sin adoptar FSD completo. De hecho, para muchos equipos es mejor empezar así. Menos ceremonia, más claridad. Cuando el proyecto crece y necesitas reglas más formales, puedes evolucionar hacia FSD.
La arquitectura no debería ser una camiseta religiosa. Debería ser una herramienta para reducir riesgo.
Cuándo sí y cuándo no usarlo
Usa vertical slicing cuando tu app ya tiene flujos claros: autenticación, búsqueda, carrito, checkout, dashboard, reportes, administración, perfil.
También úsalo cuando el equipo necesita ownership claro. Es mucho más fácil decir “tú tomas features/billing” que decir “tú tomas algunos componentes, algunos hooks y algunos services”.
No hace falta usarlo para una landing page de tres secciones. Ahí estás sobrediseñando. Tampoco lo fuerces si todavía estás explorando un prototipo que puede morir mañana.
La arquitectura buena aparece cuando responde a una presión real. Si no hay presión, no inventes peso.
Resultado final
Después de aplicar el tutorial, tu app queda organizada así:
src/
├── app/
│ ├── App.tsx
│ └── router.tsx
├── features/
│ ├── auth/
│ │ ├── api/
│ │ ├── model/
│ │ ├── ui/
│ │ └── index.ts
│ └── products/
│ ├── api/
│ ├── model/
│ ├── ui/
│ └── index.ts
├── pages/
│ ├── login/
│ │ └── LoginPage.tsx
│ └── products/
│ └── ProductsPage.tsx
├── shared/
│ └── api/
│ └── httpClient.ts
└── main.tsx
No es una estructura enorme. No intenta resolver todo. Pero ya tiene lo más importante: el código cambia cerca de la razón por la que cambia.
Esa es la idea central.
Cuando el login cambia, vas a features/auth. Cuando productos cambia, vas a features/products. Cuando aparece una pieza genérica real, recién ahí la subes a shared.
No estás ordenando archivos. Estás ordenando decisiones.
Cierre
Vertical slicing en frontend no es una moda ni una carpeta más con nombre elegante. Es una forma de diseñar el proyecto para que el equipo entienda dónde vive cada cambio.
React y Vite te dan una base liviana. La arquitectura la pones tú.
Si organizas por tipo técnico, el proyecto parece prolijo al principio y se vuelve disperso con el tiempo. Si organizas por funcionalidad, el código empieza a parecerse al producto. Y cuando el código se parece al producto, mantenerlo deja de ser una búsqueda arqueológica.
Ese es el punto: menos arqueología, más entrega con criterio.