UI Hard Parts: What Actually Goes Wrong
"Hydration failed because the initial UI does not match what was rendered on the server." — The error message that haunts Next.js developers
This chapter covers what tutorials skip: the actual hard problems you'll encounter when building Next.js applications.
Hydration Mismatches
What Is Hydration?
When the server renders HTML and sends it to the browser, that HTML is "dead"—it has no interactivity. Hydration is when React takes over that static HTML and adds event handlers, making it interactive.
The contract is simple: the HTML React expects to create must match the HTML the server sent.
When they don't match, you get a hydration error.
Why Hydration Errors Happen
Cause 1: Different Data on Server vs. Client
// This will cause a hydration error
function Timestamp() {
return <span>{new Date().toISOString()}</span>;
}
The server renders the timestamp at one moment. The client hydrates milliseconds later with a different timestamp. Mismatch.
Cause 2: Browser-Only APIs
// This will cause a hydration error
function WindowWidth() {
return <span>Width: {window.innerWidth}</span>;
}
window doesn't exist on the server. The server renders undefined, the client renders an actual number. Mismatch.
Cause 3: Invalid HTML
// This will cause a hydration error
function BadNesting() {
return (
<p>
<p>Nested paragraphs are invalid HTML</p>
</p>
);
}
The browser will auto-correct invalid HTML, creating a mismatch with what the server sent.
Cause 4: Browser Extensions
Extensions that modify the DOM (Grammarly, translation tools, etc.) can insert elements that React doesn't expect.
How to Fix Hydration Errors
Solution 1: Suppress the Warning
<span suppressHydrationWarning>{new Date().toISOString()}</span>
This is an escape hatch. It tells React "I know this will mismatch, ignore it." Use sparingly.
Solution 2: useEffect for Client-Only Logic
function WindowWidth() {
const [width, setWidth] = useState(0);
useEffect(() => {
setWidth(window.innerWidth);
}, []);
return <span>Width: {width || 'Loading...'}</span>;
}
useEffect runs only on the client after hydration, so the initial render matches the server.
Solution 3: Dynamic Imports with SSR Disabled
const ClientOnlyComponent = dynamic(
() => import('../components/ClientOnlyComponent'),
{ ssr: false }
);
This component won't render on the server at all.
Solution 4: The isMounted Pattern
function ClientComponent() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) return null;
return <ClientOnlyContent />;
}
Solution 5: useSyncExternalStore
For external state (localStorage, etc.):
import { useSyncExternalStore } from 'react';
function useTheme() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
},
() => localStorage.getItem('theme') ?? 'light',
() => 'light' // Server snapshot
);
}
The React team confirms: "Preventing hydration mismatches is exactly what the getServerSnapshot argument is designed for."
The Complexity of Rendering Modes
Next.js supports multiple rendering strategies. Understanding when to use each is one of the hardest parts.
Static Site Generation (SSG)
What it is: Pages generated at build time.
When to use:
- Content that doesn't change often
- Marketing pages, blog posts, documentation
Trade-offs:
- Fast (served from CDN)
- But requires rebuild to update
Server-Side Rendering (SSR)
What it is: Pages generated on every request.
When to use:
- Content that changes per-user or per-request
- Authenticated pages, personalized content
Trade-offs:
- Always fresh
- But slower than SSG (server must render each request)
Incremental Static Regeneration (ISR)
What it is: Static pages that revalidate in the background.
When to use:
- Content that changes, but not on every request
- E-commerce product pages, news sites
Trade-offs:
- Mostly static speed with freshness
- But stale data during revalidation window
Client-Side Rendering (CSR)
What it is: Page shell rendered on server, content loaded on client.
When to use:
- Highly interactive content
- User-specific dashboards after initial load
Trade-offs:
- Full interactivity
- But slower initial load, poor SEO for dynamic content
Partial Pre-Rendering (PPR)
What it is: Static shell with dynamic holes filled at request time.
When to use:
- Pages with both static and dynamic content
- E-commerce pages (static product info, dynamic price/availability)
How it works:
- Build time: Generate static shell with Suspense boundaries as "holes"
- Request time: Serve static shell immediately, stream dynamic content into holes
- Single HTTP request for everything
The Key Insight:
"PPR lets you render the entire page at build time, with the exception of Suspense-wrapped components. With streaming/suspense, you render the entire page on request."
Server vs. Client Components
The Mental Model
In the App Router, every component is a Server Component by default.
Server Components:
- Run only on the server
- Can access databases directly
- Can use
async/awaitat the component level - Cannot use hooks, event handlers, or browser APIs
Client Components (marked with 'use client'):
- Run on both server (for initial HTML) and client (for interactivity)
- Can use hooks and event handlers
- Cannot directly import Server Components
The Gotchas
Gotcha 1: You can't use hooks in Server Components
// This will error
async function ServerPage() {
const [count, setCount] = useState(0); // Error!
}
Gotcha 2: You can pass Server Components as children to Client Components
// This works!
function ClientWrapper({ children }) {
'use client';
return <div onClick={handler}>{children}</div>;
}
function Page() {
return (
<ClientWrapper>
<ServerComponent /> {/* This is fine */}
</ClientWrapper>
);
}
Gotcha 3: Importing a Client Component into a Server Component is fine
The boundary is determined by 'use client', not by import direction.
Edge Runtime Limitations
What the Edge Runtime Can't Do
The Edge Runtime is a lightweight JavaScript environment. It's fast because it's limited:
- No filesystem access — Can't read/write files
- No native Node.js modules — Many npm packages won't work
- No persistent connections — No traditional database connections
- Strict size limits — 1MB bundled code limit
- Short execution limits — 5 seconds on Vercel
Real-World Pain Points
JWT Authentication:
"The first red flag appeared when I tried to use my favorite libraries in middleware. I spent hours debugging why jsonwebtoken wasn't working, only to discover it uses Node.js crypto APIs that aren't available in the Edge Runtime."
Solution: Use the jose library instead of jsonwebtoken.
Database Access:
Traditional database clients (pg, mysql2) won't work on the Edge. You need Edge-compatible clients like:
- Vercel Postgres
- Neon
- PlanetScale
- Turso
Debugging:
"Middleware runs in a black box. When something goes wrong, you're left guessing. Server logs are minimal, error messages are cryptic."
When to Use (and Avoid) Edge
Use Edge for:
- Simple redirects and rewrites
- A/B testing
- Geolocation-based routing
- Bot protection
Avoid Edge for:
- Heavy computation
- Database access (unless Edge-compatible)
- Authentication (use Node.js runtime as of v15.5)
- Anything requiring npm packages with native dependencies
Common Mistakes
1. Fetching in Both Server and Client
// Server Component
async function Page() {
const data = await fetch('/api/data'); // This runs on server
return <ClientComponent initialData={data} />;
}
// Client Component
function ClientComponent({ initialData }) {
const [data, setData] = useState(initialData);
useEffect(() => {
fetch('/api/data').then(setData); // This also fetches! Wasteful.
}, []);
}
Fix: Trust the server data. Only refetch if you need real-time updates.
2. Mixing Server and Client State
// This is confusing
async function Page() {
const serverData = await getServerData();
return <ClientComponent serverData={serverData} />;
}
function ClientComponent({ serverData }) {
const [clientData, setClientData] = useState(null);
// Now you have two sources of truth
}
Fix: Decide which source of truth you need. Don't mix.
3. Ignoring Suspense Boundaries
// Without Suspense, errors crash the whole page
async function Page() {
const data = await riskyFetch();
return <div>{data}</div>;
}
Fix: Wrap risky async operations in Suspense with error boundaries.
Key Takeaways
- Hydration errors occur when server and client HTML don't match
- Use
useEffect,dynamic()withssr: false, orsuppressHydrationWarningto fix them - Know when to use SSG, SSR, ISR, CSR, and PPR
- Server Components are the default; use
'use client'for interactivity - Edge Runtime has significant limitations—use Node.js runtime for complex operations
- As of Next.js 15.5, Middleware can run on Node.js runtime