🎨 Frontend Profesional y Directo - Guías de Implementación
⚡ Filosofía
IMPORTANTE: Este proyecto se paga por la funcionalidad del CORE, no por el frontend elaborado.
Aclaración Importante
"Frontend sencillo" NO significa "mal hecho". Significa:
- ✅ Seguir las mejores prácticas de Next.js 16
- ✅ Usar App Router correctamente (Server Components, Server Actions)
- ✅ Usar Tailwind CSS profesionalmente
- ✅ Código limpio y mantenible
- ❌ Pero SIN librerías UI complejas innecesarias
Reglas de Oro
- ✅ Next.js App Router (estándar) - Estructura profesional de Next.js
- ✅ Server Components por defecto - La forma correcta de Next.js 16
- ✅ Server Actions - Para mutations (estándar de Next.js)
- ✅ Tailwind CSS - Styling profesional
- ✅ HTML semántico - Forms nativos (funcionan perfecto con Server Actions)
- ❌ NO usar: Radix UI, shadcn/ui, Framer Motion (innecesarios para este MVP)
- ❌ NO crear: Component libraries complejas, design systems
🎯 Principios de UI
Prioridad de Esfuerzo
80% - Core (RBAC, validaciones, DB, tests)
15% - Formularios funcionales
5% - Estilos básicos con Tailwind
Lo Que SÍ Necesitamos
- ✅ Formularios que funcionen
- ✅ Tablas para mostrar datos
- ✅ Botones que ejecuten acciones
- ✅ Mensajes de error/éxito
- ✅ Navegación básica
- ✅ Actualizaciones optimistas (patrón estándar para mejor UX)
Lo Que NO Necesitamos
- ❌ Diseño pixel-perfect
- ❌ Animaciones o transiciones
- ❌ Componentes reutilizables complejos
- ❌ Sistema de themes (dark/light)
- ❌ Responsive design elaborado
- ❌ Accesibilidad avanzada (ARIA)
📝 Ejemplos de Implementación
✅ CORRECTO: Formulario HTML Simple
// app/hackathons/create/page.tsx
import { createHackathon } from '@/modules/hackathons/actions';
export default function CreateHackathonPage() {
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Crear Hackathon</h1>
<form action={createHackathon} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Nombre
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="slug" className="block text-sm font-medium mb-1">
Slug
</label>
<input
type="text"
id="slug"
name="slug"
required
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-1">
Descripción
</label>
<textarea
id="description"
name="description"
required
rows={4}
className="w-full px-3 py-2 border rounded"
/>
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Crear
</button>
</form>
</div>
);
}
Por qué es correcto:
- HTML nativo
- Server Action directo
- Tailwind básico
- Sin estado complejo
- Sin validaciones del cliente (se hacen en el servidor)
⚡ Actualizaciones Optimistas (Patrón Estándar)
📋 Regla de Oro
SIEMPRE usar actualizaciones optimistas en lugar de window.location.reload() o recargas completas de página.
¿Qué son las Actualizaciones Optimistas?
Las actualizaciones optimistas actualizan el estado local de la UI inmediatamente antes de que el servidor confirme la acción. Esto proporciona:
- ✅ Feedback instantáneo al usuario
- ✅ Mejor experiencia de usuario (sin esperas)
- ✅ Sin errores de "input stream" de Next.js
- ✅ Sincronización suave con el servidor
Cuándo Usar
SÍ usar actualizaciones optimistas cuando:
- ✅ Actualizas datos en una lista/tabla (cambiar rol, eliminar usuario, etc.)
- ✅ Modificas estado que se muestra inmediatamente
- ✅ La acción es reversible o tiene rollback claro
NO usar cuando:
- ❌ La acción es crítica y no se puede revertir fácilmente
- ❌ Necesitas confirmación del servidor antes de mostrar cambios
- ❌ El estado depende de cálculos complejos del servidor
Patrón Estándar de Implementación
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { myServerAction } from '@/modules/my-module/actions';
export default function MyComponent({ initialData }: { initialData: Data[] }) {
const router = useRouter();
// 1. Estado local para actualizaciones optimistas
const [data, setData] = useState<Data[]>(initialData);
// 2. Sincronizar con props cuando cambien (después de router.refresh())
useEffect(() => {
setData(initialData);
}, [initialData]);
async function handleUpdate(id: string, newValue: string) {
// 3. Guardar valor anterior para rollback si falla
const previousItem = data.find((item) => item.id === id);
const previousValue = previousItem?.value;
// 4. ACTUALIZACIÓN OPTIMISTA: Actualizar estado local inmediatamente
setData((prev) =>
prev.map((item) => (item.id === id ? { ...item, value: newValue } : item))
);
try {
// 5. Ejecutar Server Action
const result = await myServerAction(id, newValue);
if (!result.success) {
// 6. ROLLBACK: Revertir si falla
if (previousValue) {
setData((prev) =>
prev.map((item) => (item.id === id ? { ...item, value: previousValue } : item))
);
}
// Mostrar error
setError(result.error);
} else {
// 7. SINCRONIZAR: Actualizar con datos del servidor sin recargar
router.refresh(); // Esto actualizará initialData y el useEffect sincronizará
}
} catch (error) {
// 8. ROLLBACK en caso de error
if (previousValue) {
setData((prev) =>
prev.map((item) => (item.id === id ? { ...item, value: previousValue } : item))
);
}
setError(error.message);
}
}
return (
// Renderizar con data (estado local optimista)
<div>{/* ... */}</div>
);
}
Ejemplo Real: Actualizar Rol de Usuario
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useUser } from '@clerk/nextjs';
import { updateUserRole } from '@/modules/users/actions';
import { Role } from '@prisma/client';
import type { Profile } from '@prisma/client';
export default function UsersTable({ profiles: initialProfiles }: { profiles: Profile[] }) {
const router = useRouter();
const { user } = useUser();
const [profiles, setProfiles] = useState<Profile[]>(initialProfiles);
// Sincronizar con props
useEffect(() => {
setProfiles(initialProfiles);
}, [initialProfiles]);
async function handleRoleChange(profileId: string, newRole: Role) {
if (!user?.id) return;
// Guardar rol anterior
const previousProfile = profiles.find((p) => p.id === profileId);
const previousRole = previousProfile?.role;
// ACTUALIZACIÓN OPTIMISTA
setProfiles((prev) =>
prev.map((p) => (p.id === profileId ? { ...p, role: newRole } : p))
);
try {
const result = await updateUserRole(profileId, newRole, user.id);
if (!result.success) {
// ROLLBACK
if (previousRole) {
setProfiles((prev) =>
prev.map((p) => (p.id === profileId ? { ...p, role: previousRole } : p))
);
}
setError(result.error);
} else {
// SINCRONIZAR
router.refresh();
}
} catch (error) {
// ROLLBACK
if (previousRole) {
setProfiles((prev) =>
prev.map((p) => (p.id === profileId ? { ...p, role: previousRole } : p))
);
}
setError(error.message);
}
}
return (
<table>
{profiles.map((profile) => (
<tr key={profile.id}>
<td>{profile.name}</td>
<td>
<select
value={profile.role}
onChange={(e) => handleRoleChange(profile.id, e.target.value as Role)}
>
{/* ... */}
</select>
</td>
</tr>
))}
</table>
);
}
Ejemplo Real: Eliminar Usuario
async function handleDelete(profileId: string) {
if (!user?.id) return;
// Guardar perfil para rollback
const profileToDelete = profiles.find((p) => p.id === profileId);
// ACTUALIZACIÓN OPTIMISTA: Eliminar del estado local
setProfiles((prev) => prev.filter((p) => p.id !== profileId));
try {
const result = await deleteUser(profileId, user.id);
if (!result.success) {
// ROLLBACK: Restaurar perfil
if (profileToDelete) {
setProfiles((prev) => [...prev, profileToDelete].sort(/* ... */));
}
setError(result.error);
} else {
// SINCRONIZAR
router.refresh();
}
} catch (error) {
// ROLLBACK
if (profileToDelete) {
setProfiles((prev) => [...prev, profileToDelete].sort(/* ... */));
}
setError(error.message);
}
}
Checklist de Implementación
Al implementar actualizaciones optimistas, asegúrate de:
- Estado local inicializado con props
-
useEffectpara sincronizar con props cuando cambien - Guardar valor anterior antes de actualizar
- Actualizar estado local inmediatamente (optimista)
- Ejecutar Server Action
- Rollback si falla (restaurar valor anterior)
-
router.refresh()si tiene éxito (sincronizar con servidor) - Manejo de errores con rollback
❌ NO Hacer
// ❌ MAL: Recargar toda la página
window.location.reload();
// ❌ MAL: No sincronizar con props
const [data, setData] = useState(initialData);
// Sin useEffect para sincronizar
// ❌ MAL: No hacer rollback si falla
setData(updatedData);
await serverAction();
// Si falla, el estado queda incorrecto
✅ Hacer
// ✅ BIEN: Actualización optimista con rollback
const [data, setData] = useState(initialData);
useEffect(() => setData(initialData), [initialData]);
const previous = data.find(...);
setData(updatedData); // Optimista
const result = await serverAction();
if (!result.success) {
setData(restorePrevious); // Rollback
} else {
router.refresh(); // Sincronizar
}
❌ INCORRECTO: Formulario Complejo
// ❌ NO HACER ESTO
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
export default function CreateHackathonPage() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(createHackathonSchema),
});
return (
<motion.form
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onSubmit={handleSubmit(onSubmit)}
>
<Input {...register('name')} error={errors.name?.message} />
{/* etc... */}
</motion.form>
);
}
Por qué está mal:
- Demasiadas dependencias
- Client Component innecesario
- Validación duplicada (cliente + servidor)
- Componentes personalizados complejos
- Animaciones no requeridas
✅ CORRECTO: Tabla de Datos Simple
// app/admin/users/page.tsx
import { listProfiles } from '@/modules/users/queries';
export default async function UsersPage() {
const users = await listProfiles();
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Usuarios</h1>
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="border p-2 text-left">Nombre</th>
<th className="border p-2 text-left">Email</th>
<th className="border p-2 text-left">Rol</th>
<th className="border p-2 text-left">Acciones</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="border p-2">{user.name}</td>
<td className="border p-2">{user.email}</td>
<td className="border p-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm">
{user.role}
</span>
</td>
<td className="border p-2">
<RoleSelector userId={user.id} currentRole={user.role} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Por qué es correcto:
- Server Component (data fetching en servidor)
- HTML table nativo
- Tailwind básico para estilo
- Solo un Client Component donde es necesario (RoleSelector)
✅ CORRECTO: Client Component Mínimo
// components/role-selector.tsx
'use client';
import { updateUserRole } from '@/modules/users/actions';
import { Role } from '@prisma/client';
export function RoleSelector({
userId,
currentRole
}: {
userId: string;
currentRole: Role;
}) {
async function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
const newRole = e.target.value as Role;
await updateUserRole(userId, newRole);
}
return (
<select
defaultValue={currentRole}
onChange={handleChange}
className="px-2 py-1 border rounded"
>
<option value="PARTICIPANT">Participant</option>
<option value="JUDGE">Judge</option>
<option value="ORGANIZER">Organizer</option>
<option value="SPONSOR">Sponsor</option>
</select>
);
}
Por qué es correcto:
- Client Component solo donde hay interactividad
- HTML select nativo
- Server Action para la lógica
- Sin estado complejo
🎨 Tailwind: Solo lo Básico
Clases Permitidas
/* Layout */
max-w-{size}, mx-auto, p-{n}, space-y-{n}
/* Typography */
text-{size}, font-{weight}, text-{color}
/* Backgrounds */
bg-{color}
/* Borders */
border, rounded
/* Spacing */
m-{n}, p-{n}, gap-{n}
/* Display */
flex, grid, block, hidden
/* Hover States */
hover:bg-{color}
Clases NO Necesarias
/* Animaciones */
transition-*, animate-*, motion-*
/* Transformaciones */
transform, rotate-*, scale-*
/* Efectos */
shadow-*, blur-*, backdrop-*
/* Grid/Flexbox avanzado */
grid-cols-*, auto-cols-*, place-*
📋 Checklist por Página
Para cada página nueva, verificar:
Antes de Implementar
- ¿Es absolutamente necesaria esta página?
- ¿Puedo usar un Server Component?
- ¿Necesito un Client Component? (probablemente no)
Durante Implementación
- Usar HTML nativo (form, input, table, etc.)
- Server Actions para mutations
- Tailwind solo para layout básico
- Sin librerías UI externas
Después de Implementar
- ¿Funciona? ✅
- ¿El core está bien probado? ✅
- ¿Me preocupé por el diseño? ❌ (no debería)
🚫 Lista de NO Hacer
NO Instalar
# ❌ NO instalar estas librerías
pnpm add @radix-ui/react-*
pnpm add @headlessui/react
pnpm add framer-motion
pnpm add react-hot-toast
pnpm add sonner
pnpm add vaul
pnpm add cmdk
NO Crear
- ❌
components/ui/button.tsx(con 50 variantes) - ❌
components/ui/input.tsx(wrapper de input) - ❌
components/ui/dialog.tsx(modal complejo) - ❌
lib/theme.ts(sistema de themes) - ❌
hooks/use-*.ts(hooks personalizados complejos)
NO Perder Tiempo En
- ❌ Hacer que el dropdown sea "accesible"
- ❌ Crear variantes de botones (primary, secondary, etc.)
- ❌ Responsive design perfecto
- ❌ Animaciones de loading
- ❌ Toast notifications elaboradas
- ❌ Modals con backdrop blur
✅ Lo Que SÍ Importa
Enfócate en Esto
-
Server Actions funcionan correctamente
// ✅ Esto es lo importante
export async function createHackathon(formData: FormData) {
const user = await getCurrentUser();
requireRole(user, ['ORGANIZER']); // RBAC ✅
const validated = createHackathonSchema.parse({...}); // Validación ✅
await db.hackathon.create({ data: validated }); // DB ✅
} -
Validaciones Zod exhaustivas
// ✅ Esto es lo importante
export const createHackathonSchema = z.object({
name: z.string().min(5),
slug: z.string().regex(/^[a-z0-9-]+$/),
// ... validaciones completas
}); -
Tests completos
// ✅ Esto es lo importante
describe('createHackathon', () => {
it('should create hackathon with valid data');
it('should reject if not ORGANIZER');
it('should validate slug format');
});
📊 Distribución de Tiempo
Por Fase
Fase 0 (1 semana):
├── 3 días: Core (rbac.ts, errors.ts, db.ts)
├── 2 días: Users module + tests
├── 1 día: Onboarding form + admin table
└── 1 día: Integración y ajustes
Fase 1 (5-7 días):
├── 2 días: Hackathons schema + queries + actions
├── 2 días: Validaciones + tests
├── 1 día: CRUD forms (create, edit)
├── 1 día: List + detail pages
└── 1 día: Panel de organizador (tabla simple)
Nota: El frontend nunca debe tomar más del 20% del tiempo total.
🎯 Ejemplos de Páginas Completas
Landing Page (5 minutos)
// app/page.tsx
export default function HomePage() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">PuntoHack</h1>
<p className="text-gray-600 mb-8">Plataforma de gestión de hackathons</p>
<a
href="/sign-in"
className="bg-blue-600 text-white px-6 py-3 rounded"
>
Iniciar Sesión
</a>
</div>
</div>
);
}
Panel (10 minutos)
// app/dashboard/page.tsx
import { getCurrentUser } from '@/core/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user) redirect('/sign-in');
// Redirección según rol
if (user.profile.role === 'ADMIN') redirect('/admin');
if (user.profile.role === 'ORGANIZER') redirect('/hackathons');
if (user.profile.role === 'JUDGE') redirect('/judge');
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Panel</h1>
<p>Bienvenido, {user.profile.name}</p>
<p className="text-gray-600">Rol: {user.profile.role}</p>
</div>
);
}
🚀 Deployment
El frontend minimalista también facilita el deploy:
- ✅ Build más rápido (menos dependencias)
- ✅ Bundle más pequeño
- ✅ Menos edge cases de UI
- ✅ Menos bugs de frontend
📝 Resumen
Hacer ✅
- Formularios HTML + Server Actions
- Tablas nativas para datos
- Tailwind básico (layout + colores)
- Server Components por defecto
- Client Components solo cuando es necesario
NO Hacer ❌
- Instalar librerías UI complejas
- Crear component library
- Preocuparse por diseño
- Añadir animaciones
- Hacer responsive design elaborado
Mantra 🧘
"Si funciona y demuestra el core, es suficiente."
Última Actualización: 30 de diciembre, 2025
Filosofía: Core > UI
Tiempo en Frontend: Máximo 20% del total