Client-Side Rendering and Hydration
Next.js Development
Client-Side Rendering and Hydration
In Next.js, the App Router introduces a clear distinction between Server Components and Client Components. Server Components render on the server and send HTML to the browser — they cannot use hooks, event handlers, or browser APIs. Client Components are marked with the "use client" directive and can use all of React's interactive features. When a page containing Client Components loads, the server first sends the rendered HTML, then React hydrates the page by attaching event listeners and state management to the existing DOM. Understanding this client-server boundary is essential for building performant Next.js applications.
When to Use Client Components
Use Client Components when you need interactivity: state, effects, event handlers, browser APIs, or third-party libraries that access the DOM.
// components/Counter.tsx
"use client"; // This directive marks the file as a Client Component
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// components/SearchBar.tsx
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
export default function SearchBar() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (query.length < 2) {
setSuggestions([]);
return;
}
const timer = setTimeout(async () => {
const res = await fetch(`/api/search/suggest?q=${query}`);
const data = await res.json();
setSuggestions(data.suggestions);
}, 300);
return () => clearTimeout(timer);
}, [query]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
router.push(`/search?q=${encodeURIComponent(query)}`);
}
return (
<form onSubmit={handleSubmit}>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{suggestions.length > 0 && (
<ul className="suggestions">
{suggestions.map((s, i) => (
<li key={i} onClick={() => setQuery(s)}>{s}</li>
))}
</ul>
)}
</form>
);
}
Composing Server and Client Components
The key architectural principle is to keep Server Components at the top of the tree and push Client Components to the leaves. Server Components can import and render Client Components, but Client Components cannot import Server Components — they can only accept them as children props.
// app/blog/[slug]/page.tsx — Server Component (can fetch data)
import LikeButton from "@/components/LikeButton"; // Client Component
import CommentSection from "@/components/CommentSection"; // Client Component
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// Fetch on the server — no client bundle impact
const post = await fetch(`https://api.example.com/posts/${slug}`).then(
(r) => r.json()
);
return (
<article>
{/* Static content rendered on server */}
<h1>{post.title}</h1>
<p className="meta">By {post.author} on {post.date}</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* Interactive components hydrated on client */}
<LikeButton postId={post.id} initialLikes={post.likes} />
<CommentSection postId={post.id} />
</article>
);
}
// components/LikeButton.tsx
"use client";
import { useState } from "react";
export default function LikeButton({
postId,
initialLikes,
}: {
postId: number;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
const [liked, setLiked] = useState(false);
async function handleLike() {
setLiked(!liked);
setLikes(liked ? likes - 1 : likes + 1);
await fetch(`/api/posts/${postId}/like`, { method: "POST" });
}
return (
<button onClick={handleLike} className={liked ? "liked" : ""}>
{liked ? "Liked" : "Like"} ({likes})
</button>
);
}
Hydration Best Practices
// Avoid hydration mismatches — server and client must render the same initial HTML
// BAD: This causes a hydration mismatch because Date.now() differs
// between server and client renders
"use client";
function BadComponent() {
return <p>Current time: {Date.now()}</p>; // Mismatch!
}
// GOOD: Use useEffect for client-only values
"use client";
import { useState, useEffect } from "react";
function GoodComponent() {
const [time, setTime] = useState<string>("");
useEffect(() => {
setTime(new Date().toLocaleString());
}, []);
return <p>Current time: {time || "Loading..."}</p>;
}
// GOOD: Use suppressHydrationWarning for intentional mismatches
function TimestampComponent() {
return <time suppressHydrationWarning>{new Date().toISOString()}</time>;
}
Tip: Keep Client Components as small as possible. Instead of making an entire page a Client Component, extract only the interactive parts (buttons, forms, modals) into Client Components and keep the data-fetching and layout in Server Components. This minimizes the JavaScript sent to the browser.
Key Takeaways
- Server Components are the default in the App Router — they render on the server with zero client JavaScript.
- Add
"use client"at the top of a file to create Client Components that use hooks and event handlers. - Push Client Components to the leaves of the component tree; keep data fetching in Server Components.
- Hydration attaches interactivity to server-rendered HTML — avoid mismatches between server and client output.
- Smaller Client Components mean less JavaScript shipped to the browser and better performance.