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 "@/*"
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;
}
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)
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.
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>
)
}
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>
)
}
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
}
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'
The content/ directory wasn't in the production build. Railway was copying the exported output but not the source files. Two options:
- Switch to Node.js mode (real server-side rendering)
- 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
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"
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.' }
}
}
// 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>
)
}
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
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)