Migrando Mi Portfolio a Next.js 15: De Código Legacy a App Router Moderno
¿Por Qué Reconstruir?
Mi portfolio anterior funcionaba, pero era un desastre. Construido hace años con patrones obsoletos, sin TypeScript, fuentes de datos mezcladas y cero panel de administración. Cada actualización de contenido significaba editar archivos manualmente y hacer redeploy.
Quería:
-
Stack Moderno: Next.js 15 con App Router, TypeScript en modo estricto
-
Gestión de Contenido: Panel admin para manejar todo sin tocar código
-
Mejores Imágenes: Cloudinary para optimización y transformaciones automáticas
-
Basado en Base de Datos: Supabase para contenido estructurado y consultable
-
Multi-Idioma: Soporte nativo EN/ES sin librerías
El Stack Tecnológico
Framework Core
-
Next.js 15 (App Router): Server components, API routes, middleware
-
TypeScript (modo estricto): Type safety en todas partes
-
Tailwind CSS v4: Styling con sistema de colores OKLCH
-
Shadcn/ui: Base de componentes
Backend y Storage
-
Supabase: Base de datos PostgreSQL con cliente type-safe
-
Cloudinary: Almacenamiento, optimización y entrega de imágenes
-
Variables de Entorno: Gestión segura de credenciales
Librerías Clave
-
react-markdown: Renderizado de posts con soporte GFM
-
lucide-react: Sistema de íconos
-
react-hot-toast: Notificaciones
Decisiones de Arquitectura
1. Server Components Primero
// Enfoque viejo: Todo client-side
export default function Projects() {
const [projects, setProjects] = useState([]);
useEffect(() => {
fetch('/api/projects').then(/* ... */);
}, []);
}
// Nuevo enfoque: Server component trae los datos
export default async function Projects() {
const projects = await fetchProjects();
return <ProjectsList projects={projects} />;
}
Beneficios:
-
Carga inicial más rápida
-
Mejor SEO
-
Sin spinners de loading para datos iniciales
-
Bundle más chico del cliente
2. Clientes de Supabase Separados
// Cliente público (solo lectura, RLS habilitado)
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
// Cliente admin (acceso completo, solo servidor)
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}
Esta separación asegura:
-
El sitio público solo puede leer contenido publicado
-
Las operaciones admin tienen acceso completo a la BD
-
Límites de seguridad claros
3. Autenticación con Middleware
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/admin')) {
const session = request.cookies.get('admin-session');
if (!session) {
return NextResponse.redirect(new URL('/admin/login', request.url));
}
}
}
Simple, efectivo, sin complejidad de OAuth innecesaria.
4. Operaciones de BD Type-Safe
// Tipos generados desde el schema de Supabase
export interface Project {
id: string;
title_en: string;
title_es: string;
description_en: string;
description_es: string;
image_url: string;
mobile_image_url: string | null;
technologies: string[];
github_url: string | null;
live_url: string | null;
display_order: number;
created_at: string;
}
// Type safety completo en queries
const { data, error } = await supabase
.from("projects")
.select("*")
.order("display_order");
No más adivinar nombres de campos o tipos.
El Panel de Administración
Una de las mejoras más grandes: un panel admin CRUD completo para:
-
Posts del blog (con editor markdown y preview)
-
Proyectos del portfolio (con imágenes específicas por dispositivo)
-
Items del workspace (mi setup/equipamiento)
-
Uploads de imágenes (directo a Cloudinary)
// Patrón de formulario reutilizable
export function ProjectForm({ project }: { project?: Project }) {
const [formData, setFormData] = useState({
title_en: project?.title_en ?? "",
title_es: project?.title_es ?? "",
// ...
});
const handleSubmit = async (e: React.FormEvent) => {
const response = await fetch(
project ? `/api/admin/projects/${project.id}` : '/api/admin/projects',
{
method: project ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
}
);
// Manejar response...
};
}
Características clave:
-
Edición de contenido bilingüe (campos EN/ES lado a lado)
-
Componente de upload con integración Cloudinary
-
Soporte markdown con syntax highlighting
-
Slugs auto-generados para posts
-
Toggle publicado/borrador
Implementación Multi-Idioma
Construí una solución custom en vez de usar next-intl:
// Context para el estado del idioma
export const LanguageContext = createContext<LanguageContextType | null>(null);
export function useLanguage() {
const context = useContext(LanguageContext);
if (!context) throw new Error('useLanguage must be used within LanguageProvider');
return context;
}
// Helper para seleccionar contenido
export function t<T>(en: T, es: T): T {
const { language } = useLanguage();
return language === 'es' ? es : en;
}
El uso es limpio:
const { language, t } = useLanguage();
<h1>{t(project.title_en, project.title_es)}</h1>
¿Por qué custom en vez de next-intl?
-
Más simple para mi caso (solo 2 idiomas)
-
Control total sobre la implementación
-
Sin tamaño adicional de bundle
-
Más fácil de entender y mantener
Gestión de Imágenes
Integración con Cloudinary con componente de upload reutilizable:
export function ImageUpload({ value, onChange, folder }: ImageUploadProps) {
const handleUpload = async (file: File) => {
const formData = new FormData();
formData.append('file', file);
formData.append('folder', folder);
const response = await fetch('/api/admin/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
onChange(data.secure_url);
};
// Drag & drop, input de archivo, preview...
}
Beneficios:
-
Conversión automática de formato (WebP)
-
Transformaciones on-the-fly
-
Entrega vía CDN
-
Sin necesidad de storage en servidor
Sistema de Blog
Blog basado en Markdown con control admin completo:
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeHighlight]}
components={{
a: ({ node, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}}
>
{content}
</ReactMarkdown>
Características:
-
Soporte de GitHub Flavored Markdown
-
Syntax highlighting para bloques de código
-
Styling prose custom con Tailwind Typography
-
Tiempo de lectura auto-generado
-
Posts relacionados basados en tags
-
Imágenes de portada con aspect ratios correctos
Mejoras de Performance
Antes (portfolio viejo):
-
First Contentful Paint: ~2.8s
-
Time to Interactive: ~4.1s
-
Data fetching client-side para todo
-
Sin optimización de imágenes
Después (Next.js 15):
-
First Contentful Paint: ~0.9s
-
Time to Interactive: ~1.4s
-
Server components con datos instantáneos
-
Optimización automática de imágenes vía Cloudinary + Next.js Image
Lecciones Aprendidas
1. No Sobre-Ingenierizar Temprano
Empecé con autenticación por password simple. OAuth puede venir después si hace falta.
2. TypeScript Strict Mode Desde el Día 1
Capta errores inmediatamente. Vale la pena el overhead inicial.
3. Los Server Components Son Increíbles
Menos JavaScript al cliente = páginas más rápidas. Usar client components solo cuando sea necesario.
4. Los Paneles Admin No Tienen Que Ser Lindos
Focalizarse en funcionalidad primero. Un admin funcional es mejor que uno hermoso pero roto.
5. Las Soluciones Custom Pueden Ser Mejores Que Librerías
Para i18n, ahorré bundle size y complejidad con un custom hook de 50 líneas.
Desafíos de la Migración
El Misterio del Plugin de Typography
Pasé horas debuggeando por qué el renderizado de markdown se veía roto. Resulta que @tailwindcss/typography no estaba instalado. Las clases prose no hacían nada sin él.
Fix:
npm install @tailwindcss/typography
@import "tailwindcss";
@plugin "@tailwindcss/typography";
Problemas de Line Endings en Markdown
Los line endings \r\n de Windows rompían el parsing de markdown. Solución:
const content = rawContent
.replace(/\r\n/g, "\n") // Normalizar endings de Windows
.replace(/\r/g, "\n") // Normalizar endings viejos de Mac
.replace(/\n(#{1,6}\s)/g, "\n\n$1") // Forzar líneas en blanco antes de headings
.replace(/\n{3,}/g, "\n\n"); // Máximo 2 newlines
Confusión con Supabase RLS
Las políticas de Row Level Security bloqueaban operaciones admin. Creé un cliente separado con service role para el panel admin.
Próximos Pasos
-
Agregar búsqueda de posts
-
Implementar contadores de vistas
-
Feed RSS para el blog
-
Mejoras de dark mode
-
Animaciones para transiciones de página
Conclusión
Reconstruir desde cero valió la pena. El codebase ahora es:
-
Mantenible: Patrones claros, TypeScript en todas partes
-
Escalable: Fácil agregar nuevas features
-
Rápido: Server components + edge deployment
-
Content-friendly: El panel admin hace las actualizaciones triviales
Si tu portfolio es código legacy, considerá una reconstrucción. Las herramientas modernas lo hacen más fácil que parchear código viejo.
Tech Stack:
-
Next.js 15 (App Router)
-
TypeScript
-
Tailwind CSS v4
-
Supabase
-
Cloudinary
-
Vercel
Fuente: Este portfolio que estás leyendo ahora mismo 😉
