DEV Community

Cover image for Cómo construí juanchi.dev con el stack más bleeding edge de 2025: Next.js 16, React 19, Tailwind v4 y Railway
Juan Torchia
Juan Torchia

Posted on • Originally published at juanchi.dev

Cómo construí juanchi.dev con el stack más bleeding edge de 2025: Next.js 16, React 19, Tailwind v4 y Railway

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 "@/*"
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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:

  1. Cambiar a modo Node.js (server-side rendering real)
  2. 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.' }
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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