still cooking_
6 min readnextjs

`'use client'` Does Not Mean Browser-Only

`'use client'` Does Not Mean Browser-Only

'use client' Does Not Mean Browser-Only

TL;DR: 'use client' marks the boundary where server components hand off to client components. It does not opt a component out of server-side rendering. Client components still run on the server first, then hydrate in the browser. Accessing window, document, or navigator at render time will crash.


The Common Mental Model (Wrong)

Most developers coming from the Next.js Pages Router or from plain React assume:

'use client' → only runs in the browser → window is safe

This is incorrect in the App Router.


What 'use client' Actually Means

'use client' is a module graph boundary annotation. It tells the React bundler:

"Everything imported from this module and below is allowed to use client-only APIs (hooks, event handlers, browser APIs)."

It is not a runtime environment switch. It does not skip server-side rendering.

Official reference — React docs on 'use client':

"use client marks the boundary between server and client code... it does not mean the component is not rendered on the server."

https://react.dev/reference/rsc/use-client


What Next.js 14 App Router Actually Does

Every request goes through two phases:

Phase 1 — Server render (Node.js, no window)

Next.js renders the entire component tree — including 'use client' components — on the server to generate the initial HTML. This is standard SSR. The output is sent to the browser immediately so the user sees content without waiting for JS.

During this phase, 'use client' components run in Node.js. There is no window, no document, no navigator.

Phase 2 — Browser hydration

React downloads the JS bundle and re-runs the component tree in the browser, attaching event listeners. Now window exists.

So every 'use client' component runs twice on a fresh page load: once on the server (Node.js), once on the client (browser).

Official reference — Next.js docs on Client Components:

"In Next.js, Client Components are pre-rendered on the server to generate the initial HTML."

https://nextjs.org/docs/app/getting-started/server-and-client-components


The Three Rendering Modes

ModeServer renders?Browser runs?
Server ComponentYesNo — never sent to browser
'use client' componentYes (SSR)Yes (hydration + interactions)
dynamic(() => ..., { ssr: false })NoYes only

The third mode is the only one that truly skips server rendering.

https://nextjs.org/docs/pages/guides/lazy-loading


A Real Example: The window Crash

This is the exact bug we hit in referral-cards.tsx:

// referral-cards.tsx
'use client';
 
function useReferralLink() {
  const referralCode = useUserStore(...);
  return `${window.location.origin}/invite?referral=${referralCode}`; // 💥 crashes on server
}

On a fresh page load or direct URL visit, Next.js renders this on the server. Node.js hits windowReferenceError: window is not defined.

The original code had a guard that we removed:

const origin = typeof window !== 'undefined' ? window.location.origin : 'https://acemate.ai';

This guard works but has a subtle issue: the server renders a different value than the browser (the fallback URL vs the real origin), which can cause a React hydration mismatch warning.


Why This Bug Is Easy to Miss

  1. Development mode is lenient. next dev sometimes skips SSR for navigations that happen client-side (already on the page, clicking a link). The crash only happens on a fresh load or direct URL visit.

  2. Dialogs and modals defer rendering. If ReferralCards is inside a Dialog that only mounts when the user clicks "open", the component may never render during the initial server pass — so the bug stays hidden until something changes how the dialog is rendered.

  3. Client-side navigation skips SSR. Navigating within the app uses React's client-side router — no server render, so window is always available. Only hard refreshes or direct links trigger the crash.


Safe Patterns for Browser APIs

✅ Safe — inside event handlers

Event handlers only fire in the browser. The server never calls them.

const handleCopy = () => {
  navigator.clipboard.writeText(link); // safe
};

✅ Safe — inside useEffect

React guarantees useEffect never runs during SSR. It only runs after the component has mounted in the browser.

const [origin, setOrigin] = useState('');
 
useEffect(() => {
  setOrigin(window.location.origin); // safe — browser only
}, []);

https://react.dev/reference/react/useEffect

✅ Safe — dynamic with ssr: false

For components that have no useful server-rendered output (modals, interactive widgets), skip SSR entirely:

import dynamic from 'next/dynamic';
 
const ReferralCards = dynamic(() => import('./referral-cards'), { ssr: false });

The component is not rendered on the server at all. The user sees nothing until JS loads, then the component mounts. For a modal opened by a button click, this is the right call — there is no SEO or initial paint value from SSR.

❌ Unsafe — at render time

function useReferralLink() {
  return `${window.location.origin}/invite`; // crashes on server during SSR
}

Which Fix to Use

SituationFix
Component has SEO / initial HTML valueuseEffect + useState to set after mount
Component is a modal / interactive widget with no SSR valuedynamic({ ssr: false })
You need a fallback for the server rendertypeof window !== 'undefined' guard, but watch for hydration mismatches

For ReferralCards specifically — it's a modal opened by user interaction, has no SEO value, and is never in the initial visible HTML. dynamic({ ssr: false }) is the correct fix.


Why This Came From the Pages Router

In pages/, every component was implicitly a "client component" in the sense that they all shipped to the browser. But SSR still ran on all of them — getServerSideProps was the server layer, components themselves always rendered on both sides. Developers got used to "my component is React, it's a browser thing" without realising SSR was always happening.

The App Router formalised the split with Server Components vs Client Components, but the naming of 'use client' strongly implies "this is a browser thing" — which is why the misconception is so widespread.


Why i did not use dynamic to solve this and chose guards instead?

The simplest fix without useEffect/useState is the guard directly in the hook:

  function useReferralLink() {
    const userData = useUserStore((state) => state.userData);
    const referralCode = userData.userType === 'student' ? (userData.referralCode ?? '') : '';
    const origin = typeof window !== 'undefined' ? window.location.origin : '';
    return `${origin}/invite?referral=${referralCode}`;
  }

The only consequence: on the server render, origin is '' so the link renders as /invite?referral=.... On hydration, it becomes the real URL. Since this link is inside a Dialog that's never visible in the initial HTML, there's no hydration mismatch and the user never sees the empty state.

The dynamic consequences you asked about:

  • The component's JS is split into a separate chunk — slight extra network request on first open (likely already happening anyway with code splitting)
  • No loading state by default — the component just doesn't exist until JS loads, then pops in. For a modal this is fine since it only opens on click, by which time JS has long loaded
  • TypeScript loses the named export — you have to use .then(m => m.ReferralCards) which is slightly awkward

References