A couple of weeks ago, I finally decided to properly dive into React Server Components. As mostly an Angular dev, I'd been watching React from the sidelines and learning it. RSCs had been experimental for years, but with React 19 making them stable, I figured it was time to see what the fuss was about. What started as "I'll just try this for a weekend" turned into completely rebuilding my mental model around how React works. Here's everything I learnt the hard way.
I'm not going to lie to you with some perfectly organised list. Here's what actually happened when I started this:
First, I had no idea what I was doing. Then I kind of got it. Then everything broke in new and creative ways. Then I fixed it and felt smart.
You'll learn about the server/client component thing (which confused me for way longer than I'm comfortable admitting), how to build an actual blog that doesn't suck, why my CSS randomly stopped working, and how to debug the weirdest errors you've ever seen.
Two years ago, I heard Dan Abramov talking about React Server Components at some conference. My first thought was "great, another React feature I might never use". They were experimental then, and I figured they'd stay that way forever. I was wrong.
Here's the deal: you know how your React apps send a massive JavaScript bundle to the browser, then the browser has to parse all that code, run it, make API calls, and finally show something to the user? Yeah, that's… not great. Especially when you're on a slow connection or an older phone.
Server Components flip this around. Some of your components run on the server, do all the heavy lifting there and send just the rendered HTML to the browser. The user gets content immediately, your JavaScript bundle shrinks, and everyone's happy.
I've watched React hype come and go from the Angular community. Remember when everyone was losing their minds over Concurrent Mode? As an Angular dev, I mostly ignored it. But RSCs felt different when I finally tried them for a learning project. Here's why they actually impressed me:
My Bundle Actually Got Smaller
Before, my test project's main bundle was 400KB. After: 118KB. That's not a typo. Turns out, when half your components run on the server, you don't need to ship their code to the browser. Coming from Angular, where we're used to tree-shaking and lazy loading, this was still impressive.
Data Fetching That Makes Sense
I've always heard about the useEffect, useState, setLoading dance that React devs complain about. Coming from Angular, where we have services and observables, React's data fetching always seemed unnecessarily complex. With server components, I just… fetch the data. In the component. Like a normal function. It's almost too simple.
My Lighthouse Scores Got Better
Core Web Vitals were "actually pretty good" right out of the box. As someone used to Angular Universal for SSR, this felt surprisingly effortless.
Traditional React: Everything happens in the browser. Every component, every state update, every API call - all client-side. Works fine until your bundle size starts getting massive.
React with RSCs: Some components run on the server (no client-side code), some run on the client (for interactivity). The server ones can talk to databases directly. The client ones handle clicks and form submissions. It's like having two different execution environments that somehow work together without you having to think about it too much.
Coming from Angular, this felt familiar in some ways, we've had server-side rendering with Angular Universal for years. But the way RSCs seamlessly blend server and client execution is genuinely different. No more "fetch data in useEffect, store in state, show loading spinner" dance. Just async/await in your component and you're done.
Before we start, let's make sure your computer isn't going to fight you. I learned this the hard way when I spent an hour debugging something that turned out to be a Node version issue.
Open Terminal. You know, that app you use to feel like a hacker. Run these:
node --version npm --version
You need Node 18 or higher. If you're on an older version, go to nodejs.org and download the latest LTS version.
If you don't have VS Code, get it.
code --version
I wasted time installing extensions I never use. Here's what actually matters:
Open VS Code, hit ⌘ + Shift + X
and install:
import React
for the millionth timeTime to stop reading and start breaking things. I created a folder in my Documents directory because I'm not organized enough to have a proper projects folder:
cd ~/Documents npx create-next-app@latest my-rsc-blog --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
Yeah, that command is a mouthful. But each flag matters:
--typescript
because debugging JavaScript errors at 2 AM is not fun--tailwind
because I'm bad at CSS--eslint
because I make dumb mistakes--app
because that's where RSCs live--src-dir
because I like organized projects--import-alias
because ../../../components
is uglycd my-rsc-blog npm run dev
Hit http://localhost:3000 and you should see the Next.js welcome page. If you get a port error, just use a different port:
npm run dev -- -p 3001
Open the project in VS Code:
code .
The structure looks like this:
my-rsc-blog/ ├── src/ │ └── app/ │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── favicon.ico ├── package.json ├── tailwind.config.ts └── tsconfig.json
Clean. Simple. About to become much more complicated.
This took me embarrassingly long to understand. Let me save you some pain.
By default, every component in the app
directory is a Server Component. It runs on the server. It can't use useState
or onClick
or any browser APIs because there's no browser. But it can fetch data directly from databases, keep secrets actually secret and do expensive computations without making phones burn your fingers.
Client Components have "use client"
at the top. They run in the browser. They can handle user interactions, manage state and use all the React hooks you know and love.
I created src/app/components/ServerTime.tsx
:
// No "use client" = runs on the server async function ServerTime() { // This code never reaches the browser const response = await fetch('http://worldtimeapi.org/api/timezone/Europe/London'); const data = await response.json(); return ( <div className="p-4 bg-blue-50 rounded-lg"> <h2 className="text-lg font-bold">Server Component</h2> <p>Current time: {data.datetime}</p> <p className="text-sm text-gray-600"> This was fetched on the server. Check the Network tab - no API call! </p> </div> ); } export default ServerTime;
The wild part? No useEffect. No useState. No loading states. Just async/await like a normal function. The server fetches the data, renders the HTML and sends it to the browser. The browser never sees the API call.
Then I made src/app/components/ClientCounter.tsx
:
"use client"; // This line makes all the difference import { useState } from 'react'; function ClientCounter() { const [count, setCount] = useState(0); return ( <div className="p-4 bg-green-50 rounded-lg"> <h2 className="text-lg font-bold">Client Component</h2> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)} className="mt-2 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700" > Click me! </button> <p className="text-sm text-gray-600"> This runs in your browser and handles interactions. </p> </div> ); } export default ClientCounter;
I updated src/app/page.tsx
to use both:
import ServerTime from './components/ServerTime'; import ClientCounter from './components/ClientCounter'; export default function Home() { return ( <main className="max-w-4xl mx-auto p-8"> <h1 className="text-3xl font-bold mb-8">Server vs Client Components</h1> <div className="space-y-6"> <ServerTime /> <ClientCounter /> </div> </main> ); }
Refresh the page and watch the server component's time update on each reload while the client component keeps its state. That's when it clicked for me.
Enough toy examples. Time to build something realistic for this learning exercise. A blog seemed like the perfect RSC use case with mostly static content and some interactive bits.
I wanted:
I created src/app/lib/blog-data.ts
with some mock data:
export interface BlogPost { id: string; title: string; excerpt: string; content: string; author: string; publishedAt: string; readingTime: string; tags: string[]; } const posts: BlogPost[] = [ { id: '1', title: 'Why I Finally Learned React Server Components', excerpt: 'After ignoring them for six months, I finally gave in. Here\'s what I learned.', content: 'I used to think React Server Components were just hype.', author: 'Me', publishedAt: '2025-07-15', readingTime: '5 min', tags: ['React', 'Next.js', 'Learning'] }, { id: '2', title: 'The Day CSS Broke My Soul', excerpt: 'A story about specificity, cascade and why I use Tailwind now.', content: 'Picture this: you spend three hours making your component look perfect. Then you add one CSS rule and everything breaks...', author: 'Still Me', publishedAt: '2025-06-10', readingTime: '3 min', tags: ['CSS', 'Tailwind'] } ]; // Simulate slow network because real APIs aren't instant function delay(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } export async function getBlogPosts(): Promise<BlogPost[]> { await delay(800); // Fake some loading time return posts; } export async function getBlogPost(id: string): Promise<BlogPost | null> { await delay(600); return posts.find(post => post.id === id) || null; } export async function searchPosts(query: string): Promise<BlogPost[]> { await delay(500); if (!query.trim()) return []; return posts.filter(post => post.title.toLowerCase().includes(query.toLowerCase()) || post.content.toLowerCase().includes(query.toLowerCase()) || post.tags.some(tag => tag.toLowerCase().includes(query.toLowerCase())) ); }
The delay functions are important. Real APIs have latency and you want to test your loading states.
I made src/app/components/PostCard.tsx
:
import Link from 'next/link'; import { BlogPost } from '../lib/blog-data'; interface PostCardProps { post: BlogPost; } function PostCard({ post }: PostCardProps) { return ( <article className="bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow p-6"> <div className="flex items-center text-sm text-gray-500 mb-3"> <span>{post.author}</span> <span className="mx-2">•</span> <span>{new Date(post.publishedAt).toLocaleDateString()}</span> <span className="mx-2">•</span> <span>{post.readingTime}</span> </div> <h2 className="text-xl font-bold text-gray-900 mb-3"> <Link href={`/blog/${post.id}`} className="hover:text-blue-600"> {post.title} </Link> </h2> <p className="text-gray-600 mb-4">{post.excerpt}</p> <div className="flex flex-wrap gap-2"> {post.tags.map(tag => ( <span key={tag} className="px-2 py-1 bg-gray-100 text-gray-700 text-sm rounded"> {tag} </span> ))} </div> </article> ); } export default PostCard;
Nothing fancy. Just a card that looks decent and links to individual posts.
I rewrote src/app/page.tsx
:
import { Suspense } from 'react'; import { getBlogPosts } from './lib/blog-data'; import PostCard from './components/PostCard'; import SearchForm from './components/SearchForm'; async function PostList() { const posts = await getBlogPosts(); return ( <div className="grid gap-6 md:grid-cols-2"> {posts.map(post => ( <PostCard key={post.id} post={post} /> ))} </div> ); } function LoadingSkeleton() { return ( <div className="grid gap-6 md:grid-cols-2"> {[...Array(4)].map((_, i) => ( <div key={i} className="bg-gray-200 rounded-lg h-48 animate-pulse" /> ))} </div> ); } export default function Home() { return ( <main className="max-w-6xl mx-auto px-4 py-8"> <header className="text-center mb-12"> <h1 className="text-4xl font-bold text-gray-900 mb-4">My Blog</h1> <p className="text-xl text-gray-600"> Thoughts on code, life and why everything breaks at 3 AM </p> </header> <SearchForm /> <Suspense fallback={<LoadingSkeleton />}> <PostList /> </Suspense> </main> ); }
The magic here is Suspense. While PostList
is fetching data on the server, users see the loading skeleton. No JavaScript waterfalls, no flash of loading content - just smooth progressive rendering.
I created src/app/blog/[id]/page.tsx
:
import { notFound } from 'next/navigation'; import Link from 'next/link'; import { getBlogPost } from '../../lib/blog-data'; import type { Metadata } from 'next'; interface PostPageProps { params: Promise<{ id: string }>; } // This generates metadata for each post export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> { // Next.js 15 requires awaiting params (learned this the hard way) const { id } = await params; const post = await getBlogPost(id); if (!post) { return { title: 'Post Not Found' }; } return { title: post.title, description: post.excerpt, authors: [{ name: post.author }], }; } export default async function PostPage({ params }: PostPageProps) { const { id } = await params; const post = await getBlogPost(id); if (!post) { notFound(); // Shows 404 page } return ( <main className="max-w-4xl mx-auto px-4 py-8"> <Link href="/" className="text-blue-600 hover:text-blue-800 mb-8 inline-block"> ← Back to Blog </Link> <article className="prose prose-lg max-w-none"> <header className="mb-8"> <h1 className="text-4xl font-bold text-gray-900 mb-4">{post.title}</h1> <div className="flex items-center text-gray-600 mb-6"> <span>{post.author}</span> <span className="mx-2">•</span> <span>{new Date(post.publishedAt).toLocaleDateString()}</span> <span className="mx-2">•</span> <span>{post.readingTime}</span> </div> <div className="flex flex-wrap gap-2"> {post.tags.map(tag => ( <span key={tag} className="px-2 py-1 bg-gray-100 text-gray-700 text-sm rounded"> {tag} </span> ))} </div> </header> <div className="text-gray-800 leading-relaxed"> {post.content} </div> </article> </main> ); }
Each post gets its own URL, proper metadata and server-side rendering. The generateMetadata
function runs on the server and populates the <head>
tag with the right information.
Search forms need to be interactive, so I made src/app/components/SearchForm.tsx
a client component:
"use client"; import { useState } from 'react'; import { useRouter } from 'next/navigation'; function SearchForm() { const [query, setQuery] = useState(''); const router = useRouter(); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (query.trim()) { router.push(`/search?q=${encodeURIComponent(query)}`); } }; return ( <form onSubmit={handleSubmit} className="mb-8"> <div className="flex gap-2 max-w-md mx-auto"> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search posts..." className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> <button type="submit" className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500" > Search </button> </div> </form> ); } export default SearchForm;
Then I created the search results page at src/app/search/page.tsx
:
import { Suspense } from 'react'; import Link from 'next/link'; import { searchPosts } from '../lib/blog-data'; import PostCard from '../components/PostCard'; interface SearchPageProps { searchParams: Promise<{ q?: string }>; } async function SearchResults({ query }: { query: string }) { const results = await searchPosts(query); if (results.length === 0) { return ( <div className="text-center py-12"> <p className="text-gray-600 text-lg"> No posts found for "{query}". Try different keywords? </p> </div> ); } return ( <div className="grid gap-6 md:grid-cols-2"> {results.map(post => ( <PostCard key={post.id} post={post} /> ))} </div> ); } export default async function SearchPage({ searchParams }: SearchPageProps) { // Another Next.js 15 thing - searchParams must be awaited const { q: query = '' } = await searchParams; return ( <main className="max-w-6xl mx-auto px-4 py-8"> <Link href="/" className="text-blue-600 hover:text-blue-800 mb-6 inline-block"> ← Back to Blog </Link> <h1 className="text-3xl font-bold text-gray-900 mb-8"> Search Results {query && <span className="text-gray-600"> for "{query}"</span>} </h1> {query ? ( <Suspense fallback={<div>Searching...</div>}> <SearchResults query={query} /> </Suspense> ) : ( <p className="text-gray-600">Enter a search term to find posts.</p> )} </main> ); }
Beautiful. Client component handles the form interaction, server component handles the search logic and rendering. They work together without you having to think about it.
No tutorial would be complete without the disasters. Here are the real problems I hit and how I fixed them.
This error message haunted my dreams: "Route used params.id
. params
should be awaited before using its properties."
What happened? I upgraded to Next.js 15 and suddenly all my dynamic routes broke. Turns out Next.js 15 changed how params work. You can't just do params.id
anymore. You have to await the whole params object first.
The fix was annoying but simple:
// This breaks in Next.js 15 export default function BlogPost({ params }: { params: { id: string } }) { const post = getBlogPost(params.id); // ERROR! } // This works export default async function BlogPost({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; // Must await first const post = await getBlogPost(id); }
Same thing with searchParams
. Everything has to be awaited now. I spent 1 hour debugging this before I found the migration guide.
One day my styles just… disappeared. Everything worked, but it looked like a website from 1995. The HTML had all the Tailwind classes, but none of the CSS was loading.
Turns out Next.js 15 ships with Tailwind CSS v4, which has a completely different configuration system. I had to downgrade to v3:
npm uninstall tailwindcss @tailwindcss/postcss npm install tailwindcss@^3.4.1 autoprefixer
Then update postcss.config.mjs
:
const config = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; export default config;
Clear the cache and restart:
rm -rf .next && npm run dev
Styles came back. Crisis averted.
Sometimes Next.js just loses its mind and can't find its own build files:
ENOENT: no such file or directory, open '.next/build-manifest.json'
The fix is always the same:
rm -rf .next npm run dev
This happens more than it should, but it's usually a quick fix. I've started doing this automatically whenever weird stuff happens.
Sometimes TypeScript decides to be helpful and throws type errors for things that definitely should work. The nuclear option:
rm -rf node_modules/.cache rm -rf .next npm install npm run dev
This clears all the type caches and usually fixes whatever TypeScript was complaining about.
RSCs are supposed to be performant, but you still need to be smart about it.
Since blog posts don't change often, I can pre-generate them at build time:
// Add this to your blog post page export async function generateStaticParams() { const posts = await getBlogPosts(); return posts.map(post => ({ id: post.id })); }
Now when I build the site, Next.js creates static HTML files for every blog post. Lightning fast loading with zero server work.
The Suspense boundaries I added aren't just for loading states - they enable streaming. The server sends the page shell immediately, then streams in content as it becomes available. Users see something right away instead of staring at a blank page.
Want to see what's actually in your JavaScript bundle?
npm install --save-dev @next/bundle-analyzer
Add this to next.config.js
:
const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); module.exports = withBundleAnalyzer({});
Run ANALYZE=true npm run build
and you'll get a visual breakdown of your bundle size. It's quite satisfying to see how small your client bundle is with RSCs.
After building this learning project, here's what actually stuck:
The biggest shift isn't technical, it's mental. In traditional React, you think in terms of client-side state and effects. With RSCs, you think in terms of where code runs and what data each component needs. It's simpler in some ways, more complex in others.
You can't just stick "use client"
everywhere and call it a day. Each boundary has performance implications. Server components are great for data fetching and initial rendering. Client components are necessary for interactivity. Choose wisely.
The App Router with RSCs feels mature now. The developer experience is solid, the performance benefits are real and the ecosystem is catching up. There are still some rough edges (like the params thing), but it's a big improvement over pages router.
With server and client components mixed together, error boundaries become critical. A failing server component can break your entire page if you're not careful about error handling.
Once you get past the initial confusion, RSCs are straightforward. Server components fetch data and render HTML. Client components handle interactions. The framework handles the complexity of making them work together.
React Server Components aren't just hype. They're a genuine improvement to how React apps can be built. The performance benefits are real, the developer experience is better than traditional React (once you learn the quirks) and the mental model makes sense, especially coming from server-side frameworks.
Is it perfect? No. There's a learning curve, some confusing error messages and you have to think more carefully about where your code runs. But for content-heavy sites like blogs, marketing pages, or e-commerce, RSCs could be compelling enough to consider React for projects where I'd normally reach for Angular.
The learning project I built demonstrates the core concepts: server components for data fetching, client components for interactivity, proper loading states and error handling. From here, you could add authentication, comments, a CMS, or whatever your actual projects need.
Most importantly: experiment with stuff. Break stuff. Fix stuff. Read error messages carefully (they're usually helpful). Don't be afraid to delete .next and restart when things get weird. And remember, if you're confused as an Angular dev learning React, you're not alone. The concepts are different, but they start to make sense with practice.
Now go build something cool. And when you inevitably hit a weird error I didn't mention, you'll figure it out. That's what we do.