내 코드가 서버에서 실행된 줄도 몰랐다 (ReferenceError: window is not defined)
1. "내 브라우저가 안 보이나요?"
React(CRA)로 만든 잘 돌아가는 프로젝트를 Next.js로 마이그레이션 하던 중이었습니다.
화면 너비에 따라 레이아웃을 반응형으로 바꾸는 간단한 헤더 컴포넌트였습니다.
/* Header.tsx */
const width = window.innerWidth; // 💥 에러 발생!
if (width > 768) {
return <DesktopMenu />;
}
브라우저에서 돌려보니... 아니, 브라우저 화면이 뜨기도 전에 터미널에서 에러가 폭발했습니다.
Server Error
ReferenceError: window is not defined
"아니, 지금 내 모니터에 브라우저 창(window)이 멀쩡히 떠 있는데, 왜 없다고 하는 거야?"
저는 Next.js가 제 컴퓨터를 무시하는 줄 알았습니다.
2. 범인은 서버(Server)였다
이 에러의 진범은 서버 사이드 렌더링(SSR)이었습니다.
Next.js(특히 App Router)는 사용자가 사이트에 접속하면, 일단 서버(Node.js)에서 HTML을 미리 그립니다(Pre-rendering).
이것이 React(Client Side Rendering)와의 가장 큰 차이점입니다.
Node.js 환경을 생각해 보세요. 거기엔 브라우저가 없습니다.
당연히 window, document, navigator (window.navigator), localStorage 같은 객체도 존재하지 않습니다.
(Node.js엔 process, global 같은 게 있죠.)
서버가 제 컴포넌트를 실행해서 HTML을 만들려고 하다가, window.innerWidth라는 코드를 만났고,
"이게 뭐야? 난 이런 거 모르는데?" 하고 브라우저에 도달하기도 전에 사망(Crash)해버린 것입니다.
3. 해결책 1 - 클라이언트에게 미루기 (useEffect)
가장 정석적인 해결책은 "서버에서는 실행하지 말고, 브라우저에서만 실행해"라고 코드에게 알려주는 것입니다.
React의 useEffect 훅은 렌더링이 끝난 후(Mount), 즉 브라우저 환경에서만 실행된다는 점을 이용합니다.
'use client'; // 클라이언트 훅을 쓰려면 필수!
import { useState, useEffect } from 'react';
export default function Header() {
const [width, setWidth] = useState(0); // 초기값은 0이나 안전한 값
useEffect(() => {
// 💡 여기는 브라우저 내부! window에 안전하게 접근 가능
setWidth(window.innerWidth);
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
// 뒷정리(Cleanup)도 잊지 마세요
return () => window.removeEventListener('resize', handleResize);
}, []);
// Hydration Mismatch 방지 (초기 로딩 중엔 아무것도 안 보여줌)
if (width === 0) return null;
return width > 768 ? <DesktopMenu /> : <MobileMenu />;
}
이 방법의 단점은 깜빡임(Flash of Unstyled Content)이 발생할 수 있다는 것입니다.
초기엔 width가 0이라 아무것도 안 보이다가, JS가 로드된 후 갑자기 메뉴가 뿅 하고 나타날 수 있습니다.
4. 해결책 2 - 방어 코드 작성 (typeof check)
반드시 렌더링 중에 값을 확인해야 한다면, 방어적 코드(Defensive Coding)를 심어야 합니다.
// window가 정의되어 있는지 타입 체크
const isBrowser = typeof window !== 'undefined';
const width = isBrowser ? window.innerWidth : 0;
이렇게 하면 서버에서는 isBrowser가 false가 되어 width가 0이 되므로 에러는 안 납니다.
하지만 이 방법은 Hydration Mismatch Warning을 유발할 수 있습니다.
Warning: Prop className did not match. Server: "mobile" Client: "desktop"
- 서버: width가 0이라서
<MobileMenu>를 그려서 보냄.
- 브라우저: width가 1920이라서
<DesktopMenu>를 그림.
- React: "어? 서버가 준 HTML이랑 내가 그린 HTML이 다른데?" (동공지진)
그래서 SSR을 쓴다면, 화면의 첫 모양새는 서버와 클라이언트가 일치해야 합니다. (해결책 1처럼 로딩 상태를 두는 게 안전합니다.)
5. 해결책 3 - 라이브러리가 말썽일 때 (Dynamic Import)
제가 짠 코드가 아니라, 외부 라이브러리(예: 지도 react-map-gl, 에디터 react-quill, 차트 apexcharts)가 내부적으로 window를 쓰는 경우가 있습니다.
이건 제가 useEffect로 감쌀 수도 없어서 난감합니다. import 하자마자 터지니까요.
이럴 땐 Next.js의 필살기, Dynamic Import로 서버 렌더링을 아예 꺼버려야 합니다.
import dynamic from 'next/dynamic';
// 💡 ssr: false 옵션 = "이 컴포넌트는 서버에서 건너뛰어!"
const Map = dynamic(() => import('react-map-gl'), {
ssr: false,
loading: () => <p>지도를 불러오는 중...</p> // 로딩 컴포넌트 (Skeleton)
});
export default function Page() {
return (
<div>
<h1>우리 회사 위치</h1>
<Map /> {/* 이제 서버 에러 없이 렌더링 됨 */}
</div>
);
}
이제 서버는 Map 자리에 로딩 텍스트(Skeleton)만 HTML로 그려서 보내고, 실제 무거운 지도 JS 파일은 브라우저에서 나중에 로드합니다.
에러도 잡고, 초기 로딩 속도도 빨라지는 일석이조 효과입니다.
6. 꿀팁: suppressHydrationWarning
정말로 어쩔 수 없이 서버와 클라이언트의 값이 달라야 할 때가 있습니다.
예를 들어 현재 시간을 보여주거나, 랜덤 숫자를 생성할 때입니다.
서버 렌더링 시점과 클라이언트 렌더링 시점의 시간이 0.001초라도 다르면 Mismatch 에러가 납니다.
이럴 때 React에게 "이건 내가 알고 있으니까 조용히 해"라고 말하는 속성이 있습니다.
<span suppressHydrationWarning>
{new Date().toLocaleTimeString()}
</span>
suppressHydrationWarning을 붙이면 해당 엘리먼트(속성 포함)에 대해서는 불일치 경고를 무시합니다.
단, 텍스트 노드 불일치에만 작동하며 깊게(Deep) 적용되지 않으니 주의하세요. 남용하면 버그를 찾기 힘들어집니다.
7. 번외 - 환경 변수(Environment Variable)도 없다?
window가 없는 것과 비슷하게, 브라우저에서는 환경 변수도 사라지는 마법을 경험할 수 있습니다.
Next.js는 보안을 위해 NEXT_PUBLIC_ 접두사가 없는 환경 변수는 브라우저로 보내지 않습니다.
process.env.DB_PASS -> 서버에서만 접근 가능 (브라우저에선 undefined)
process.env.NEXT_PUBLIC_GA_ID -> 브라우저에서도 접근 가능
window is not defined 만큼이나 자주 겪는 undefined 에러의 주범이니 꼭 체크하세요.
8. 마무리 - "내 코드는 어디서 실행되는가?"
React 개발자가 Next.js로 넘어올 때 겪는 첫 번째 성장통이 바로 이 window 에러입니다.
이 에러를 만났다는 건, 이제 여러분의 코드가 두 세계(서버와 클라이언트)를 오가기 시작했다는 증거입니다.
코드를 짤 때 항상 자문해 보세요.
"이 줄은 지금 서버에서 실행되는가, 브라우저에서 실행되는가?"
이 감각을 익히면 Next.js가 훨씬 쉬워집니다. 그리고 window가 없다고 당황하지 않고, 우아하게 useEffect나 dynamic을 꺼내 들게 될 것입니다.
I Didn't Know My Code Was Running on Server (ReferenceError: window is not defined)
1. "Can't You See My Browser?"
I was migrating a perfectly working React (CRA) project to Next.js.
It was a simple responsive Header component that changed layout based on screen width.
/* Header.tsx */
const width = window.innerWidth; // 💥 Crash!
if (width > 768) {
return <DesktopMenu />;
}
I ran it, and before the browser window even opened, the terminal exploded with errors.
Server Error
ReferenceError: window is not defined
"Wait, my browser window is right there on my monitor! Why are you saying it's undefined?"
I thought Next.js was gaslighting me.
2. The Culprit Was the Server
The true villain was Server-Side Rendering (SSR).
In Next.js (especially App Router), when a user visits a site, the server (Node.js) pre-renders the HTML first.
This is the biggest difference from standard React (Client Side Rendering).
Think about the Node.js environment. It has no browser.
Naturally, browser-specific objects like window, document, navigator (window.navigator), and localStorage do not exist.
(Node.js has process and global instead.)
The server tried to execute my component to generate HTML, encountered window.innerWidth, said "What on earth is this?", and crashed before it even reached the browser.
3. Solution 1: Defer to Client (useEffect)
The standard fix is to play dumb on the server and tell the code: "Don't run on the server, only run in the browser."
We use React's useEffect hook because it runs only after the render (Mount), meaning it's guaranteed to run in the browser environment.
'use client'; // Required to use hooks!
import { useState, useEffect } from 'react';
export default function Header() {
const [width, setWidth] = useState(0); // Initial value 0 or a safe default
useEffect(() => {
// 💡 This is inside the browser! window exists!
setWidth(window.innerWidth);
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
// Don't forget cleanup
return () => window.removeEventListener('resize', handleResize);
}, []);
// Prevent Hydration Mismatch (Don't show anything initially)
if (width === 0) return null;
return width > 768 ? <DesktopMenu /> : <MobileMenu />;
}
The downside is a potential Flash of Unstyled Content (FOUC).
Initially width may be 0 (showing nothing), then JS loads, and the menu suddenly pops in.
4. Solution 2: Defensive Coding (typeof check)
If you absolutely must check a value during rendering, use a guard clause (Defensive Coding).
// Check if window is defined
const isBrowser = typeof window !== 'undefined';
const width = isBrowser ? window.innerWidth : 0;
This prevents the crash because on the server isBrowser is false, making width 0.
However, this leads to a huge risk of Hydration Mismatch Warning.
Warning: Prop className did not match. Server: "mobile" Client: "desktop"
- Server: Sees width 0, renders
<MobileMenu>.
- Browser: Sees width 1920, renders
<DesktopMenu>.
- React: "Wait, the HTML the server gave me is different from what I drew!" (Panic)
So if you use SSR, the initial UI must match between Server and Client. (Solution 1 is safer).
5. Solution 3: When Libraries Misbehave (Dynamic Import)
Sometimes it's not your code, but an external library (e.g., Maps react-map-gl, Editors react-quill, Charts apexcharts) that uses window internally.
You can't wrap their internal code in useEffect. The app crashes as soon as you import them.
For this, use Next.js's ultimate weapon: Dynamic Import to completely disable SSR for that component.
import dynamic from 'next/dynamic';
// 💡 ssr: false option = "Skip this on the server!"
const Map = dynamic(() => import('react-map-gl'), {
ssr: false,
loading: () => <p>Loading map...</p> // Loading state (Skeleton)
});
export default function Page() {
return (
<div>
<h1>Our Office</h1>
<Map /> {/* Now renders without server error */}
</div>
);
}
Now the server just renders the Loading text (Skeleton) as HTML, and the actual heavy Map JS loads solely in the browser later.
It fixes the error AND speeds up initial load. Win-win.
6. Advanced: suppressHydrationWarning
Sometimes, you know the mismatch is harmless and you just want React to shut up.
For example, generating a random number or showing the current timestamp.
<div suppressHydrationWarning>
{new Date().toLocaleTimeString()}
</div>
By adding suppressHydrationWarning to an element, React will not complain if the Server HTML and Client HTML differ for that specific element (one level deep only).
Use this sparingly. If you overuse it, you might miss real layout bugs.
7. Bonus: Environment Variables on Server vs Client
Another common "undefined" error source in Next.js is Environment Variables.
- Server: Can access
process.env.DB_PASSWORD.
- Client: Can ONLY access variables starting with
NEXT_PUBLIC_.
If you try to log console.log(process.env.API_KEY) in a Client Component (and the variable doesn't start with NEXT_PUBLIC_), it will be undefined.
This is a security feature to prevent leaking secrets to the browser.
But it often looks like a bug to beginners.
Rule:
- Secret Key (DB Password, Stripe Secret) -> Server Only -> No Prefix
- Public Key (Analytics ID, Firebase Config) -> Client Safe -> Add
NEXT_PUBLIC_ Prefix
8. The Ultimate Hammer: Dynamic Import
If useEffect feels too manual and you want to import a heavy library like react-leaflet (Map) or apexcharts (Charts) that completely breaks on the server, use Next.js Dynamic Imports.
import dynamic from 'next/dynamic';
// This component will NOT be imported on the server at all.
const MapComponent = dynamic(() => import('./Map'), {
ssr: false, // Core magic: Disable SSR for this component
loading: () => <p>Loading Map...</p>,
});
export default function Page() {
return (
<div>
<h1>Location</h1>
<MapComponent />
</div>
);
}
With ssr: false, Next.js creates a strict boundary. The server renders a placeholder (or nothing), and the browser fetches the chunk and renders the component.
This completely bypasses any window errors inside MapComponent.