Hay una pregunta que me hice durante meses antes de arrancar con juanchi.dev: ¿uso el stack probado o me tiro de cabeza con lo más nuevo y aguanto los golpes?
Elegí los golpes. Siempre elijo los golpes.
Esto es lo que pasó cuando intenté montar un portfolio de desarrollador con Next.js 16, React 19, Tailwind v4 y Railway en producción — con todo lo que salió mal documentado en tiempo real, porque alguien tiene que hacerlo.
El setup inicial: la arrogancia de los primeros 20 minutos
Empecé con confianza de CEO de startup imaginaria. Tres comandos y ya:
npx create-next-app@latest juanchi-dev \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
Bien. Proyecto andando. Tailwind v4 instalado automáticamente porque usé el flag correspondiente. Acá fue cuando me di cuenta que v4 no tiene tailwind.config.js por defecto — toda la configuración vive en el CSS directamente:
@import "tailwindcss";
@theme {
--font-family-display: "Inter Variable", sans-serif;
--color-brand: oklch(62% 0.25 240);
--color-brand-dark: oklch(45% 0.25 240);
--breakpoint-xs: 20rem;
}
Esto es raro al principio. Muy raro. Durante dos horas busqué dónde meter mi extend de colores personalizados hasta que leí la documentación de verdad. Con v4, el archivo CSS es la configuración. Una vez que lo internalizás, es hermoso. Hasta entonces, duele.
React 19 y los Server Components: amigos con beneficios que te complican la vida
La idea era simple: portfolio estático en su mayoría, con algunas partes dinámicas. Uso de Server Components para todo lo que pueda, Client Components solo donde necesito interactividad.
Acá está la estructura que terminé usando:
src/
app/
page.tsx → Server Component (hero + about)
projects/
page.tsx → Server Component (fetch de proyectos)
[slug]/
page.tsx → Server Component (detalle del proyecto)
blog/
page.tsx → Server Component
contact/
page.tsx → mezcla de los dos mundos
components/
ui/ → Client Components (animaciones, forms)
server/ → Server Components (cards, layouts)
El problema vino con las animaciones. Quería ese efecto de entrada donde cada sección aparece al hacer scroll. Usé framer-motion y el compilador me mandó directo al carajo:
Error: useState can only be used in a Client Component.
Add the "use client" directive at the top of the file.
Claro. framer-motion necesita el DOM. Solución: wrapper client-side que envuelve los Server Components:
// components/ui/animated-section.tsx
'use client'
import { motion } from 'framer-motion'
import { ReactNode } from 'react'
interface AnimatedSectionProps {
children: ReactNode
delay?: number
}
export function AnimatedSection({ children, delay = 0 }: AnimatedSectionProps) {
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}
Y en el Server Component:
// app/page.tsx (Server Component)
import { AnimatedSection } from '@/components/ui/animated-section'
import { HeroContent } from '@/components/server/hero-content'
export default function HomePage() {
return (
<main>
<AnimatedSection>
<HeroContent />
</AnimatedSection>
</main>
)
}
El patrón de "wrapper client, contenido server" es la clave. Lo entendí tarde, pero lo entendí.
El sistema de proyectos: MDX + generación estática
Para los proyectos decidí usar archivos MDX locales. Sin CMS, sin base de datos, sin dependencias externas para el contenido. Los archivos viven en el repo.
// lib/projects.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
const projectsDir = path.join(process.cwd(), 'content/projects')
export interface Project {
slug: string
title: string
description: string
stack: string[]
year: number
liveUrl?: string
repoUrl?: string
featured: boolean
content: string
}
export async function getAllProjects(): Promise<Project[]> {
const files = fs.readdirSync(projectsDir)
return files
.filter(f => f.endsWith('.mdx'))
.map(filename => {
const slug = filename.replace('.mdx', '')
const raw = fs.readFileSync(path.join(projectsDir, filename), 'utf8')
const { data, content } = matter(raw)
return {
slug,
title: data.title,
description: data.description,
stack: data.stack ?? [],
year: data.year,
liveUrl: data.liveUrl,
repoUrl: data.repoUrl,
featured: data.featured ?? false,
content
}
})
.sort((a, b) => b.year - a.year)
}
export async function getProjectBySlug(slug: string): Promise<Project | null> {
const projects = await getAllProjects()
return projects.find(p => p.slug === slug) ?? null
}
Esto funciona perfecto en local. En Railway empezó el drama.
Railway: el deployment que casi me quiebra
Railway es mi plataforma de hosting favorita para proyectos propios. Precio razonable, DX excelente, deploys desde GitHub automáticos. Pero con Next.js 16 hay que tener cuidado con algo: el output mode.
Por defecto, Next.js genera un bundle que asume que tenés Node.js disponible en runtime. Railway lo maneja bien, pero el fs.readdirSync que uso para leer los archivos MDX no funciona si configurás output: 'export' (modo totalmente estático).
Yo, genio que soy, lo había puesto en output: 'export' porque quería el deploy más rápido posible. El resultado:
Error: ENOENT: no such file or directory, scandir '/app/content/projects'
El directorio content/ no estaba en el build de producción. El problema era que Railway copiaba el output exportado pero no los archivos fuente. Dos opciones:
- Cambiar a modo Node.js (server-side rendering real)
- Mantener export pero pre-generar todo en build time
Elegí el modo Node.js porque igual necesitaba el endpoint de contacto con lógica server-side:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// Sin output: 'export' — modo Node.js
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'github.com'
}
]
},
experimental: {
optimizePackageImports: ['framer-motion', 'lucide-react']
}
}
export default nextConfig
Y el railway.toml que me salvó la vida:
[build]
builder = "nixpacks"
buildCommand = "npm run build"
[deploy]
startCommand = "npm run start"
healthcheckPath = "/"
healthcheckTimeout = 30
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 3
[[services]]
name = "juanchi-dev"
El formulario de contacto: Server Actions al rescate
Con Next.js 15+ y React 19, los Server Actions son ciudadanos de primera clase. El formulario de contacto que manda un mail fue el lugar perfecto para usarlos:
// app/contact/actions.ts
'use server'
import { Resend } from 'resend'
import { z } from 'zod'
const resend = new Resend(process.env.RESEND_API_KEY)
const ContactSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
message: z.string().min(10).max(2000)
})
export async function sendContactEmail(
prevState: { success: boolean; error?: string } | null,
formData: FormData
) {
const raw = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message')
}
const parsed = ContactSchema.safeParse(raw)
if (!parsed.success) {
return { success: false, error: 'Datos inválidos. Revisá los campos.' }
}
try {
await resend.emails.send({
from: 'contacto@juanchi.dev',
to: 'yo@juanchi.dev',
subject: `Nuevo contacto: ${parsed.data.name}`,
text: `De: ${parsed.data.email}\n\n${parsed.data.message}`
})
return { success: true }
} catch (error) {
console.error('Error enviando mail:', error)
return { success: false, error: 'Error al enviar. Probá de nuevo.' }
}
}
// app/contact/contact-form.tsx
'use client'
import { useActionState } from 'react'
import { sendContactEmail } from './actions'
export function ContactForm() {
const [state, action, isPending] = useActionState(sendContactEmail, null)
return (
<form action={action} className="flex flex-col gap-4">
<input
name="name"
placeholder="Tu nombre"
className="border border-neutral-700 bg-neutral-900 px-4 py-3 rounded-lg"
required
/>
<input
name="email"
type="email"
placeholder="tu@mail.com"
className="border border-neutral-700 bg-neutral-900 px-4 py-3 rounded-lg"
required
/>
<textarea
name="message"
placeholder="En qué puedo ayudarte..."
rows={5}
className="border border-neutral-700 bg-neutral-900 px-4 py-3 rounded-lg resize-none"
required
/>
<button
type="submit"
disabled={isPending}
className="bg-brand text-white py-3 rounded-lg disabled:opacity-50"
>
{isPending ? 'Enviando...' : 'Mandar mensaje'}
</button>
{state?.success && <p className="text-green-400">¡Mensaje enviado!</p>}
{state?.error && <p className="text-red-400">{state.error}</p>}
</form>
)
}
useActionState es el hook nuevo de React 19 que reemplaza al viejo patrón de useFormState de react-dom. Más limpio, mejor tipado, manejo de pending nativo.
Lo que salió mal: el resumen ejecutivo
Para los que llegaron acá directamente desde el título buscando el drama:
1. Tailwind v4 rompió todos mis snippets guardados. Las utilities cambiaron sutilmente. text-sm sigue existiendo pero los valores por defecto son diferentes. Pasé 40 minutos debuggeando un font-size que "se veía raro" hasta que lo medí con DevTools.
2. framer-motion con React 19 tuvo un bug de hidratación la primera semana. Se resolvió actualizando a framer-motion@12.x. Lección: cuando usás bleeding edge, los paquetes de terceros se quedan atrás.
3. Railway construía bien pero el healthcheck fallaba porque el servidor tardaba más de 10 segundos en responder al primer request (cold start). Solución: aumentar healthcheckTimeout a 30 segundos en el railway.toml.
4. Los tipos de TypeScript de Next.js 16 para algunos parámetros de layouts y pages cambiaron. params ahora es una Promise en algunos contextos. Esto me rompió tres archivos.
// Antes (Next.js 14):
export default function ProjectPage({ params }: { params: { slug: string } }) {
// Ahora (Next.js 15/16):
export default async function ProjectPage(
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params
¿Lo volvería a hacer?
Sí. Sin dudas.
Hay algo en trabajar con stack bleeding edge que te fuerza a leer documentación de verdad, a entender por qué las cosas funcionan y no solo cómo. Cuando algo rompe en territorio desconocido no podés copypastear Stack Overflow — tenés que pensar.
Y el resultado final es un portfolio de desarrollador con Next.js y Railway que carga en menos de 1.2 segundos, tiene Lighthouse a 98/100, corre en producción por menos de 5 dólares al mes, y lo más importante: lo entiendo de punta a punta.
La tech nueva duele al principio. Después de eso, es una ventaja competitiva.
El código fuente de juanchi.dev va a estar público en GitHub cuando termine de limpiar los comentarios avergonzantes del proceso. Pronto.
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)