What I Learned Building This Website With Next.js 15 (And What I'd Do Differently)

This website — the one you're reading right now — runs on Next.js 15 with the App Router, React 19, and a mix of Server and Client Components. It's also how I learned most of the lessons in this post, usually the hard way.
I'm not going to give you a tutorial-style walkthrough of Next.js 15 features. The docs do that better than I can. Instead, I'll share the things that surprised me, the patterns that actually mattered for performance, and the mistakes I made so you don't have to.
Architecture principles here also apply to AI applications. See production LLM scaling for backend patterns, cost optimization for infrastructure, and edge computing for distributed architectures.
The Big Win: Server Components Aren't Optional Anymore
When Next.js 13 introduced Server Components, I was skeptical. "Another rendering paradigm to learn? I'll stick with getServerSideProps."
I was wrong. Server Components are the single most impactful performance feature in modern React, and Next.js 15 makes them the default for a reason.
On this site, moving from a client-heavy architecture to Server Components by default cut our initial JavaScript bundle from ~400KB to ~200KB. That's a 50% reduction, and the pages feel noticeably faster — especially on mobile.
The rule I follow: everything is a Server Component unless it needs interactivity. Animation? Client Component. Click handler? Client Component. Static content, data fetching, layout? Server Component. You'd be surprised how little of a typical page actually needs client-side JavaScript.
app/
├── page.js # Server Component (static content + data)
├── components/
│ ├── HeroContent.js # Server Component (static HTML)
│ ├── QuickNav.js # Server Component (links, no JS needed)
│ ├── TypewriterRole.js # Client Component (animation)
│ └── ScrollIndicator.js # Client Component (scroll behavior)
On my homepage, only the typewriter animation and scroll indicator are Client Components. Everything else — the hero section, navigation cards, content blocks — renders on the server and sends zero JavaScript to the browser.
The Caching Gotcha That Cost Me a Day
Next.js 15 changed caching defaults, and it caught me off guard.
In Next.js 14, fetch() calls in Server Components were cached by default. In 15, they're not cached by default. I didn't notice this when upgrading, and suddenly my page load times doubled because every render was hitting the database fresh.
The fix was simple — add explicit caching:
async function getBlogPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // Cache for 1 hour
});
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
}
But the lesson was bigger: don't assume caching behavior. Be explicit. I now add caching directives to every data-fetching function, even when I think the defaults might be correct. Future upgrades won't surprise me again.
Patterns That Actually Matter at Scale
Streaming with Suspense
This is the pattern that made the biggest UX difference. Instead of waiting for everything to load, show what you have immediately and stream the rest:
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* This renders instantly */}
<Suspense fallback={<UsersSkeleton />}>
<Users /> {/* This streams in when ready */}
</Suspense>
<Suspense fallback={<MetricsSkeleton />}>
<Metrics /> {/* This streams in independently */}
</Suspense>
</div>
);
}
The skeleton fallbacks aren't just cosmetic. They prevent layout shift, which is a Core Web Vitals killer. I design skeletons to match the exact dimensions of the loaded content.
Dynamic Imports for Heavy Components
My site has a 3D particle background. It's cool but it's ~150KB of JavaScript that nobody needs on first load. Dynamic import with ssr: false:
const LazyParticles = dynamic(
() => import('@/components/Particles'),
{ ssr: false, loading: () => <div className="bg-gradient-dark" /> }
);
The page loads instantly with a gradient background. The particles appear a second later. Users perceive a fast page that gets more interesting over time — much better than a slow page that arrives complete.
Server Actions for Form Handling
Server Actions replaced my API routes for form handling and made the code dramatically simpler:
'use server'
async function submitContactForm(formData) {
const name = formData.get('name');
const email = formData.get('email');
const message = formData.get('message');
// Validate, sanitize, save to DB
await db.contact.create({ data: { name, email, message } });
revalidatePath('/contact');
return { success: true };
}
No API route file, no fetch call on the client, no loading state management. The form just works. Input validation still matters — I use zod on the server side — but the boilerplate reduction is real.
What I'd Do Differently
Use TypeScript from day one. I started this project in JavaScript and migrated partway through. The migration was painful — not because TypeScript is hard, but because retroactively adding types to existing components uncovered dozens of subtle bugs. Starting with TypeScript would have caught them as I wrote them.
Be more aggressive with Server Components. My first pass left too many components as Client Components "just in case." Going back and converting them to Server Components improved performance measurably. Start with everything as Server, convert to Client only when you get a runtime error about hooks or event handlers.
Set up performance monitoring before launch. I added Core Web Vitals monitoring after noticing the site felt slow on mobile. Should have been day one. Vercel Analytics or a self-hosted solution — either way, you need data.
Don't over-engineer the folder structure. I started with an elaborate feature-based folder structure. For a personal site with 20 pages, it was overkill. The simple App Router conventions (app/, components/, lib/) are fine for most projects. Add complexity when you actually have 10 developers and 100 pages.
The Architecture That Works
After all the iteration, here's what I landed on:
app/
├── page.js # Homepage (Server Component)
├── blog/[slug]/page.js # Blog posts (Server + Client mix)
├── api/contact/route.ts # Contact API (rate limited)
├── loading.js # Global loading skeleton
components/
├── home/ # Homepage-specific
│ ├── HeroContent.js # Server Component
│ ├── TypewriterRole.js # Client Component ('use client')
│ └── AchievementsSection.js # Client Component (CountUp animations)
├── ui/ # shadcn/ui components
lib/
├── utils/
└── hooks/
Server Components by default — only add 'use client' when the component genuinely needs browser APIs or event handlers.
Dynamic imports for anything heavy — particles, animation libraries, charts. Let the core content load fast.
Suspense boundaries around async operations — streaming makes pages feel instant even when data fetching is slow.
Explicit caching on every data fetch — no surprises when Next.js changes defaults.
These aren't revolutionary patterns. They're boring, well-understood techniques applied consistently. Which is exactly what production web development should be.
Quick Performance Wins
If you're working on a Next.js app and want quick improvements:
- Audit your
'use client'directives. Every Client Component ships JavaScript. Can any of them be Server Components instead? - Add
next/imageto every image. Automatic WebP/AVIF conversion, lazy loading, and responsive sizing. Free performance. - Check your bundle size. Run
npx @next/bundle-analyzerand look for surprises. I found a 180KB date formatting library that I replaced with a 2KB utility. - Add
loading.jsfiles. One file per route segment gives you instant loading states. Prevents the "blank screen" during navigation. - Set
revalidateon your fetches. Don't hit the database on every page view. Even a 60-second cache makes a huge difference at scale.
None of these take more than an hour, and they compound.
Building a Next.js application and want an architecture review? I've shipped production Next.js apps with everything from 10 to 100K+ monthly users. Let's talk.
