Next.js App Router: The Mental Model

App Router confused me at first. Here's the mental model that made it click.

Server by Default

The key insight: in App Router, **everything is a Server Component by default**.

This is backwards from how we've thought about React for years.

The Two Worlds

Server Components

  • Run on the server only
  • Can access databases directly
  • Can use `async/await` at the component level
  • Cannot use hooks like `useState`, `useEffect`
  • Cannot handle user events
// This runs on the server
async function UserList() {
  const users = await db.users.findMany();
  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

Client Components

  • Run in the browser (and server for initial render)
  • Can use hooks
  • Can handle events
  • Must opt-in with `'use client'` directive
'use client';

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

The Boundary

When you add `'use client'`, that component AND all its children become client components.

// This is a Server Component
function Page() {
  return (
    <div>
      <ServerContent />      {/* Server */}
      <InteractiveWidget />  {/* Client (and children) */}
    </div>
  );
}

Composition Pattern

Keep interactive parts small. Pass server data down:

// Server Component
async function ProductPage({ id }) {
  const product = await db.products.find(id);
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      {/* Only the button needs to be client */}
      <AddToCartButton productId={product.id} />
    </div>
  );
}
// Client Component - small and focused
'use client';

function AddToCartButton({ productId }) {
  const [loading, setLoading] = useState(false);
  
  const handleClick = async () => {
    setLoading(true);
    await addToCart(productId);
    setLoading(false);
  };
  
  return <button onClick={handleClick}>{loading ? 'Adding...' : 'Add to Cart'}</button>;
}

Data Fetching

No more `useEffect` + `useState` for data:

// Old way (Client Component)
function Users() {
  const [users, setUsers] = useState([]);
  
  useEffect(() => {
    fetch('/api/users').then(r => r.json()).then(setUsers);
  }, []);
  
  return /* ... */;
}

// New way (Server Component)
async function Users() {
  const users = await db.users.findMany();
  return /* ... */;
}

Less code, faster page loads, no loading states (for initial render).

Once this mental model clicks, App Router feels natural.

Back to Journal