The useEffect era
For years, every React component I wrote that needed data looked roughly like this:
function BlogList() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { fetch('/api/posts') .then(res => res.json()) .then(data => setPosts(data)) .catch(err => setError(err)) .finally(() => setLoading(false)); }, []); if (loading) return <Spinner />; if (error) return <ErrorMessage />; return posts.map(post => <BlogCard key={post.id} {...post} />); }
Three pieces of state for one fetch. A useEffect that runs after the first render, which means the user sees a loading spinner before they see any content. And if you forget the cleanup function or the dependency array, you get stale data or infinite loops.
I wrote this pattern hundreds of times. I had it memorized. I even had a custom useFetch hook that abstracted it away. But the fundamental problem was always there: the component renders empty first, then fetches, then re-renders with data. The user always waits.
What server components actually changed
When I moved this site to Next.js App Router, the blog listing page became this:
export default async function BlogPage() { const blogs = await blogUtils.getAllBlogsPosts(); return ( <div className="flex flex-col"> <div className="flex items-center justify-between"> <h1 className="text-4xl font-bold tracking-tight glow-text">Latest Blogs</h1> <span className="text-sm font-mono text-muted-foreground"> {`${blogs.length} Articles`} </span> </div> <div className="grid gap-4 sm:grid-cols-2 mt-8"> {blogs.map((blog) => ( <BlogCard key={blog.id} title={blog.data.title} description={blog.data.description} href={`/blog/${blog.id}`} readingTime={blogUtils.getReadingTime(blog.content)} /> ))} </div> </div> ); }
That's the actual code from app/blog/page.tsx. No useState. No useEffect. No loading state. No error boundary. The function is async, it awaits the data, and returns JSX. The data is just there when the component renders.
The first time I wrote a component like this it felt wrong. Like I was forgetting something. Where's the loading state? Where's the error handling? But the thing is, this component runs on the server. By the time the HTML reaches the browser, the data is already baked in. There's nothing to load.
The blog post page
The individual blog post page is more interesting because it does multiple things at once:
export default async function BlogItem({ params }: BlogItemProps) { const { slug } = await params; const [blogPost] = await Promise.all([ blogUtils.getBlogPostById(slug), createOrUpdateBlogInDb(slug), ]); const content = blogPost.content || ''; const tocHeadings = extractHeadings(content); const hasToC = tocHeadings.length > 0; return ( <div className="relative pt-8"> {/* ... title, tags, reading time ... */} <div className={hasToC ? 'lg:grid lg:grid-cols-[220px_1fr] lg:gap-8' : ''}> {hasToC && <TableOfContents headings={tocHeadings} />} <div> <article className="prose max-w-none"> <MarkdownRenderer>{content}</MarkdownRenderer> </article> </div> </div> </div> ); }
Promise.all runs the blog post fetch and the database upsert in parallel. In the old world this would have been two useEffects or a single useEffect with Promise.all inside it, plus loading states for both, plus error handling for both, plus a re-render when they resolve. Here it's just two awaited promises before the return statement.
The heading extraction, the ToC logic, the conditional grid layout — all of that happens server-side too. The browser gets fully rendered HTML with the layout already decided. No layout shift, no flash of content without a sidebar.
Where client components still matter
Not everything can be a server component. The like counter on this site is a good example of the split.
The server part fetches the data:
// like-counter.tsx (server component) export async function LikeCounterServer({ blogTitle }: LikeCounterProps) { const cookieStore = await cookies(); const [postLikes, hasUserLikedAlready] = await Promise.all([ getLikeCount(blogTitle), getIsPostLikedByUser(cookieStore.get('uuid')?.value, blogTitle), ]); return ( <LikeCounter hasUserLikedAlready={hasUserLikedAlready} likes={postLikes} blogTitle={blogTitle} /> ); }
The client part handles the interaction:
// like-counter.client.tsx 'use client'; export function LikeCounter({ likes, blogTitle, hasUserLikedAlready }: LikeCounterProps) { const [localLikes, addLocalLikes] = useState<number>(likes); const [isLiked, setIsLiked] = useState<boolean>(hasUserLikedAlready); function handleIconClick() { addLocalLikes((prev) => prev + 1); setIsLiked(true); } return ( <form action={(formData) => addLikesToPost(formData)}> <Button onClick={handleIconClick}> <ThumbsUp className={isLiked ? 'fill-current' : ''} /> </Button> <span>{new Intl.NumberFormat().format(localLikes)}</span> </form> ); }
The server component does the database queries. The client component handles the click and the optimistic UI update. The initial like count arrives with the page HTML — no spinner, no flash of "0 likes" before the real count loads.
The old version of this would have been a single client component that fetches the count on mount, shows a spinner, then renders the button. Every visitor would see that spinner for a split second. With server components, the count is already there.
Suspense as the new loading state
The like counter is wrapped in Suspense on the blog post page:
<Suspense fallback={ <div className="flex items-center justify-center p-2"> <Loader2 className="h-5 w-5 animate-spin text-primary" /> </div> } > <LikeCounterServer blogTitle={slug} /> </Suspense>
This is the part that took me the longest to internalize. Suspense isn't just for lazy loading anymore. It's the mechanism for streaming. The blog post content renders immediately. The like counter streams in when the database query finishes. If the database is slow, the rest of the page isn't blocked.
In the useEffect world, you'd either block the whole page on all data, or you'd have multiple independent loading states scattered around the component. Suspense boundaries let you choose exactly which parts of the page can load independently. It's a much better model.
The theme toggle problem
The header on this site has a dark/light mode toggle. It has to be a client component because it uses useTheme from next-themes, which needs access to browser APIs:
'use client'; export function Header() { const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); function handleThemeToggle() { setTheme(theme === 'dark' ? 'light' : 'dark'); } return ( <header> <Button onClick={handleThemeToggle}> {!mounted ? <span /> : theme === 'dark' ? <Sun /> : <Moon />} </Button> </header> ); }
See that mounted state? That's there to prevent a hydration mismatch. The server doesn't know what theme the user prefers, so it renders a placeholder. After hydration, the client swaps in the correct icon. Without that check you get a flash of the wrong icon or a React hydration error.
This is the kind of thing that makes the server/client boundary feel real. The theme toggle needs useState, useEffect, and event handlers. It has to be 'use client'. But the blog content, the navigation links, the footer — none of that needs client-side JavaScript. It can all stay on the server.
The mental shift
The biggest change isn't technical. It's how I think about components now.
Before, every component was a client component by default. If it needed data, I'd add a useEffect. If it needed interactivity, I'd add a useState. Everything ran in the browser.
Now I start with the opposite assumption. Every component is a server component until it needs to be on the client. Need data? Just await it. Need to read from the filesystem? Go ahead, you're on the server. Need a click handler or some local state? OK, that one gets 'use client'.
Most of my components don't need interactivity. The blog listing page, the blog post page, the about page, the footer, the category pages — they're all server components. They fetch data, render HTML, and ship it to the browser. No JavaScript bundle for any of them.
The components that are 'use client' on this site: the header (theme toggle), the like button, the Table of Contents (scroll tracking with IntersectionObserver), the guest wall form, and the game/effect components that use Canvas. That's it. Everything else is server-rendered.
What I still don't love
Error handling is weird. In a useEffect you'd catch the error and set an error state. With server components, if the await throws, you need an error.tsx boundary file in the right directory. It works but it's less explicit. I've had errors silently break pages because I forgot to add the boundary.
Caching and revalidation are confusing. Next.js has revalidatePath, revalidateTag, unstable_cache, the fetch cache options... I still don't have a clear mental model for when data is fresh vs stale. The defaults have changed between Next.js versions too, which doesn't help.
And the 'use client' boundary can be surprising. If a server component imports a client component, that's fine. But if a client component tries to import a server-only module, it breaks. I ran into this recently with a utility function that was in a 'use client' file — I had to extract it into a separate module so the server component could use it too.
Where I landed
I'm not going back to useEffect for data fetching. The server component model is just better for the kind of sites I build. Most pages are content-driven. They need data at render time, not after mount. Server components give you that without any of the loading state ceremony.
The client components I do write are smaller and more focused. They handle one specific interaction instead of being responsible for both fetching and rendering. The like button doesn't know how to query the database. It just receives a number and a click handler.
If you're still writing useEffect + useState + loading spinners for every page, try converting one page to a server component. Just make the function async, await the data, and remove the state. See how it feels. It felt wrong to me at first too.