DEV Community

Cover image for How I Built juanchi.dev on the Most Bleeding-Edge Stack of 2025: Next.js 16, React 19, Tailwind v4 & Railway
Juan Torchia
Juan Torchia

Posted on • Originally published at juanchi.dev

How I Built juanchi.dev on the Most Bleeding-Edge Stack of 2025: Next.js 16, React 19, Tailwind v4 & Railway

For months before I started juanchi.dev, I kept asking myself the same question: do I go with the proven stack, or do I dive headfirst into the newest stuff and just take the hits?

I chose the hits. I always choose the hits.

This is what happened when I tried to ship a developer portfolio with Next.js 16, React 19, Tailwind v4, and Railway to production — with everything that went wrong documented in real time, because someone has to do it.


The initial setup: the arrogance of the first 20 minutes

I started with the energy of an imaginary startup CEO. Three commands and done:

npx create-next-app@latest juanchi-dev \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"
Enter fullscreen mode Exit fullscreen mode

Clean. Project running. Tailwind v4 installed automatically because I used the right flag. That's when I noticed v4 has no tailwind.config.js by default — all configuration lives directly in the CSS:

@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

This is weird at first. Really weird. I spent two hours looking for where to put my custom color extend until I actually read the documentation. With v4, the CSS file is the config. Once that clicks, it's beautiful. Until it does, it hurts.


React 19 and Server Components: friends with benefits who complicate your life

The idea was simple: mostly static portfolio, a few dynamic parts. Server Components for everything I could manage, Client Components only where I needed interactivity.

Here's the structure I ended up with:

src/
  app/
    page.tsx          → Server Component (hero + about)
    projects/
      page.tsx        → Server Component (fetch projects)
      [slug]/
        page.tsx      → Server Component (project detail)
    blog/
      page.tsx        → Server Component
    contact/
      page.tsx        → mix of both worlds
  components/
    ui/               → Client Components (animations, forms)
    server/           → Server Components (cards, layouts)
Enter fullscreen mode Exit fullscreen mode

The problem showed up with animations. I wanted that scroll-in entrance effect where each section fades up into view. I reached for framer-motion and the compiler told me to go straight to hell:

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

Right. framer-motion needs the DOM. The fix: a client-side wrapper that hugs the 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

And in the 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

The "client wrapper, server content" pattern is the key. I figured it out late, but I figured it out.


The projects system: MDX + static generation

For projects I decided to go with local MDX files. No CMS, no database, no external content dependencies. The files live in the 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

Works perfectly local. On Railway, the drama started.


Railway: the deployment that nearly broke me

Railway is my favorite hosting platform for personal projects. Reasonable pricing, excellent DX, automatic deploys from GitHub. But with Next.js 16 you need to be careful about one thing: the output mode.

By default, Next.js generates a bundle that assumes you have Node.js available at runtime. Railway handles that fine, but the fs.readdirSync I use to read MDX files does not work if you set output: 'export' (fully static mode).

Me, genius that I am, had set output: 'export' because I wanted the fastest possible deploy. The result:

Error: ENOENT: no such file or directory, scandir '/app/content/projects'
Enter fullscreen mode Exit fullscreen mode

The content/ directory wasn't in the production build. Railway was copying the exported output but not the source files. Two options:

  1. Switch to Node.js mode (real server-side rendering)
  2. Keep export but pre-generate everything at build time

I went with Node.js mode because I needed the contact endpoint with server-side logic anyway:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  // No output: 'export' — Node.js mode
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'github.com'
      }
    ]
  },
  experimental: {
    optimizePackageImports: ['framer-motion', 'lucide-react']
  }
}

export default nextConfig
Enter fullscreen mode Exit fullscreen mode

And the railway.toml that saved my life:

[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

The contact form: Server Actions to the rescue

With Next.js 15+ and React 19, Server Actions are first-class citizens. The contact form that sends an email was the perfect place to use them:

// 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: 'Invalid data. Check your fields.' }
  }

  try {
    await resend.emails.send({
      from: 'contact@juanchi.dev',
      to: 'me@juanchi.dev',
      subject: `New contact: ${parsed.data.name}`,
      text: `From: ${parsed.data.email}\n\n${parsed.data.message}`
    })

    return { success: true }
  } catch (error) {
    console.error('Error sending email:', error)
    return { success: false, error: 'Send failed. Try again.' }
  }
}
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="Your name"
        className="border border-neutral-700 bg-neutral-900 px-4 py-3 rounded-lg"
        required
      />
      <input
        name="email"
        type="email"
        placeholder="you@email.com"
        className="border border-neutral-700 bg-neutral-900 px-4 py-3 rounded-lg"
        required
      />
      <textarea
        name="message"
        placeholder="How can I help you..."
        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 ? 'Sending...' : 'Send message'}
      </button>
      {state?.success && <p className="text-green-400">Message sent!</p>}
      {state?.error && <p className="text-red-400">{state.error}</p>}
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

useActionState is the new React 19 hook that replaces the old useFormState pattern from react-dom. Cleaner, better typed, native pending handling built right in.


What broke: the executive summary

For everyone who jumped straight here from the title looking for the carnage:

1. Tailwind v4 broke every saved snippet I had. The utilities shifted subtly. text-sm still exists but the default values are different. I spent 40 minutes debugging a font-size that "looked off" until I measured it in DevTools.

2. framer-motion with React 19 had a hydration bug the first week. Fixed by upgrading to framer-motion@12.x. Lesson: when you're on bleeding edge, third-party packages lag behind. Every time.

3. Railway was building fine but the healthcheck kept failing because the server took more than 10 seconds to respond to the first request (cold start). Fix: bump healthcheckTimeout to 30 seconds in railway.toml.

4. Next.js 16 TypeScript types for certain layout and page params changed. params is now a Promise in some contexts. This broke three of my files.

// Before (Next.js 14):
export default function ProjectPage({ params }: { params: { slug: string } }) {

// Now (Next.js 15/16):
export default async function ProjectPage(
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
Enter fullscreen mode Exit fullscreen mode

Would I do it again?

Yes. Without a second thought.

There's something about working with a bleeding-edge stack that forces you to actually read documentation, to understand why things work and not just how. When something breaks in unknown territory you can't just copypaste Stack Overflow — you have to think.

And the end result is a developer portfolio with Next.js and Railway that loads in under 1.2 seconds, scores 98/100 on Lighthouse, runs in production for under $5 a month, and — most importantly — I understand it end to end.

New tech hurts at first. After that, it's a competitive advantage.


The juanchi.dev source code will be public on GitHub once I finish cleaning up the embarrassing comments from the process. Soon.

Top comments (0)