How to Fix: Trying to load a browser-only module on non-browser environment

6 min read

Your app is importing a browser-only module somewhere that also runs in a non-browser environment such as Node.js, server-side rendering, a test runner, or a build-time execution path. The result is usually a crash around window, document, localStorage, or a package that assumes the DOM exists.

Understanding the Root Cause

This issue happens when code that depends on the browser runtime is evaluated outside the browser. In modern React applications, that can happen more often than expected:

  • SSR frameworks execute components and modules on the server before sending HTML to the client.
  • Build tools may statically evaluate imports.
  • Tests may run in a Node environment instead of jsdom.
  • Shared utility files may be imported by both client and server code paths.

The important detail is that top-level imports are executed immediately. If a package touches window or document as soon as it is imported, simply importing it in a server file is enough to trigger the failure.

This is especially common when combining state and data libraries like @reduxjs/toolkit and @tanstack/react-query inside universal apps, because store setup, query clients, persistence helpers, and API wrappers are often placed in shared modules. If one of those shared modules imports a browser-only dependency, the server will try to load it too.

Typical examples of browser-only code include:

  • Direct access to window, document, navigator, or localStorage
  • Packages that render UI or manipulate the DOM during import
  • Persistence libraries that assume browser storage exists
  • Analytics, auth, editor, map, or file APIs intended only for the client

Step-by-Step Solution

The fix is to isolate browser-only code so it only runs on the client. The safest approach is a combination of three rules:

  1. Do not import browser-only modules in server-executed files.
  2. Guard browser APIs with runtime checks.
  3. Lazy-load browser-only dependencies inside client-side lifecycle code.

1. Find the offending import

Search for imports of libraries that use DOM APIs or storage APIs. Also inspect shared files such as:

  • store configuration
  • query client setup
  • app providers
  • utility modules
  • custom hooks

A problematic pattern looks like this:

import browserOnlyLib from 'browser-only-lib';

const value = browserOnlyLib.init();

export default value;

If that file is imported by server code, it will break immediately.

2. Guard browser globals

If the issue comes from direct use of browser APIs, wrap them in a runtime check:

export function getStorageItem(key) {
  if (typeof window === 'undefined') {
    return null;
  }

  return window.localStorage.getItem(key);
}

This prevents server-side execution from touching browser globals.

3. Move browser-only imports into client-only execution

If the package itself is browser-only, do not import it at the top of a shared module. Load it dynamically in a client-safe place:

export async function loadBrowserModule() {
  if (typeof window === 'undefined') {
    return null;
  }

  const mod = await import('browser-only-lib');
  return mod;
}

Then call it from a client lifecycle boundary:

import { useEffect } from 'react';
import { loadBrowserModule } from './loadBrowserModule';

export function ClientFeature() {
  useEffect(() => {
    async function setup() {
      const mod = await loadBrowserModule();
      if (!mod) return;

      mod.default?.init?.();
    }

    setup();
  }, []);

  return null;
}

4. Keep Redux and React Query setup server-safe

When using @reduxjs/toolkit or @tanstack/react-query, keep your base configuration free of browser-only dependencies.

Safe Redux store setup:

import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './rootReducer';

export function createStore() {
  return configureStore({
    reducer: rootReducer,
  });
}

Avoid this in shared store code:

import storage from 'browser-storage-lib';

const persisted = storage.getItem('state');

Instead, hydrate persisted state only in the browser:

export function loadPreloadedState() {
  if (typeof window === 'undefined') {
    return undefined;
  }

  try {
    const raw = window.localStorage.getItem('app-state');
    return raw ? JSON.parse(raw) : undefined;
  } catch {
    return undefined;
  }
}
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './rootReducer';
import { loadPreloadedState } from './loadPreloadedState';

export function createStore() {
  return configureStore({
    reducer: rootReducer,
    preloadedState: loadPreloadedState(),
  });
}

Safe React Query client setup:

import { QueryClient } from '@tanstack/react-query';

export function createQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: 1,
      },
    },
  });
}

If you use persistence plugins or browser storage for caching, initialize them only on the client:

import { QueryClient } from '@tanstack/react-query';

export async function setupQueryPersistence(queryClient) {
  if (typeof window === 'undefined') {
    return;
  }

  const { persistQueryClient } = await import('@tanstack/react-query-persist-client');

  const persister = {
    persistClient: async (client) => {
      window.localStorage.setItem('rq-cache', JSON.stringify(client));
    },
    restoreClient: async () => {
      const cached = window.localStorage.getItem('rq-cache');
      return cached ? JSON.parse(cached) : undefined;
    },
    removeClient: async () => {
      window.localStorage.removeItem('rq-cache');
    },
  };

  persistQueryClient({
    queryClient,
    persister,
  });
}

5. Split server-safe and client-only modules

A clean long-term fix is to separate files by runtime:

// storage.server.js
export function getClientStorage() {
  return null;
}
// storage.client.js
export function getClientStorage() {
  return window.localStorage;
}

Then import the correct file based on your framework conventions or your bundler setup.

6. If your framework supports client components, mark them clearly

In frameworks with a server/client component split, browser-dependent code must stay in a client component. For example, any file that uses effects, storage, or DOM APIs should not be treated as server-renderable.

'use client';

import { useEffect } from 'react';

export function BrowserOnlyWidget() {
  useEffect(() => {
    console.log(window.location.href);
  }, []);

  return null;
}

Even then, keep in mind that importing a browser-only package at the wrong shared boundary can still cause problems, so client-only placement and lazy loading are both valuable.

Common Edge Cases

  • Top-level side effects: Even if your component only uses the library inside useEffect, a top-level import can still fail before the effect runs.
  • Transitive dependencies: Your code may look server-safe, but one imported package may internally import a browser-only dependency.
  • Test environment mismatch: A module may work in the browser and fail in unit tests running under pure Node. Switch to jsdom only if the code is genuinely browser-based.
  • Hydration mismatches: Conditional rendering based on typeof window can produce different server and client output if not handled carefully.
  • Persistence plugins: Query or Redux persistence often assumes localStorage exists. Initialize persistence separately from core store/query setup.
  • Framework route loaders: Loaders, API routes, and server actions always run outside the browser, so never import client-only modules there.

A practical debugging trick is to temporarily remove suspect imports one by one from shared setup files until the server starts again. The first import that restores normal execution is usually the browser-only boundary violation.

FAQ

Can I fix this just by checking typeof window !== 'undefined'?

Only if the failure comes from your own code accessing browser globals. If the package crashes during import, the check is not enough because the module has already been evaluated. In that case, use dynamic import and move loading into a client-only path.

Why does this happen even though I only use the module in one component?

Because ES module imports are hoisted and executed at load time. If the file containing that import is reachable from server-rendered code, the server may evaluate it before your component logic runs.

Are @reduxjs/toolkit or @tanstack/react-query causing the bug directly?

Usually no. Both libraries are generally server-compatible. The issue is typically caused by browser-only plugins, storage persistence, or app-specific code placed inside shared setup files that are imported by both client and server code.

The reliable fix is to keep your core app setup runtime-agnostic, move browser dependencies behind client-only boundaries, and lazy-load modules that require the DOM or browser storage. Once you do that, the non-browser environment stops trying to evaluate code it cannot support.

Leave a Reply

Your email address will not be published. Required fields are marked *