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:

Trade-offs:

Server-Side Rendering (SSR)

What it is: Pages generated on every request.

When to use:

Trade-offs:

Incremental Static Regeneration (ISR)

What it is: Static pages that revalidate in the background.

When to use:

Trade-offs:

Client-Side Rendering (CSR)

What it is: Page shell rendered on server, content loaded on client.

When to use:

Trade-offs:

Partial Pre-Rendering (PPR)

What it is: Static shell with dynamic holes filled at request time.

When to use:

How it works:

  1. Build time: Generate static shell with Suspense boundaries as "holes"
  2. Request time: Serve static shell immediately, stream dynamic content into holes
  3. 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:

Client Components (marked with 'use client'):

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:

  1. No filesystem access — Can't read/write files
  2. No native Node.js modules — Many npm packages won't work
  3. No persistent connections — No traditional database connections
  4. Strict size limits — 1MB bundled code limit
  5. 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:

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:

Avoid Edge for:


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


Sources