Migrando Mi Portfolio a Next.js 15: De Código Legacy a App Router Moderno
24 de enero de 20267 min de lectura12 vistas

Migrando Mi Portfolio a Next.js 15: De Código Legacy a App Router Moderno

nextjstypescriptmigrationsupabasecloudinary

¿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 😉