brokeinprod
brokeinprod
Theme

Debugging

The Next.js "fs is not defined" error in client components, fixed

Why Next.js throws "Can't resolve 'fs'" when a client component imports server-only code — and the file-split that actually fixes it (not the webpack stub).

July 16, 2026

The Next.js "fs is not defined" Error in Client Components — and the Fix That Isn't a Webpack Stub

I knew I was in trouble the moment a one-line import in a header component lit up the whole build with Can't resolve 'fs' — pointing at a file I wasn't even editing.

Start here: This error means a Client Component is pulling in a module that — directly or transitively — imports Node's fs. fs only exists on the server, so when that code gets bundled for the browser, the build breaks. The fix is not the popular config.resolve.fallback = { fs: false } webpack stub — that just hides the symptom. The fix is to split your server-only file I/O into its own module and make sure Client Components only ever import the pure, fs-free helpers.

The setup

I have a content site that reads MDX articles off disk at build time. The file that does the reading — call it lib/articles.ts — imports Node's fs to walk the content/ directory. That's correct: reading files is a server-side job, and in the App Router a Server Component (any component without 'use client') can call it safely, because that code never ships to the browser.

The trouble started when I added a tiny helper to that same file: a function that turns a pillar slug like trail-dogs into a display name like Trail Dogs. Harmless string logic. I imported it into my header — which is a Client Component, because it has an interactive mobile menu. The build immediately died:

Module not found: Can't resolve 'fs'

Or, depending on the Next.js version and how the import chain resolves, the friendlier variant:

You're importing a component that needs "fs". That only works in a Server Component, but one of its parents is marked with "use client", so it's a Client Component.

The symptom that makes this confusing

The error points at fs, but I wasn't importing fs in my header. I was importing a one-line string helper. That's the trap: the bundler doesn't import functions, it imports modules. The moment my Client Component imported anything from lib/articles.ts, it pulled the entire module into the client bundle — including the top-of-file import fs from "fs" that the file-reading functions depend on.

This is why the error feels like it's coming from nowhere. The component that triggers it and the code that actually touches fs can be several imports apart. It's transitive: A imports B imports C, and C is the one with fs. Next.js correctly refuses to bundle fs for the browser, but the stack trace points at the symptom, not the source.

The diagnosis

The rule underneath all of this: a module that imports node:fs (directly or through anything it imports) can never be reached from a Client Component. Client Components run in the browser, and the browser has no file system. There's no clever config that changes that — fs genuinely does not exist there.

So when you see this error, the question isn't "how do I make fs work in the browser" (you can't). It's "what import chain is dragging a server-only module into my client bundle?" Trace from the Client Component the error names, follow its imports, and you'll find the file with fs at the bottom.

The fix: split file I/O from pure helpers

The durable fix is structural. Server-only file I/O lives in one module; pure, dependency-free helpers live in another. Client Components import only from the pure one.

In my case:

lib/articles.ts
// lib/articles.ts  — SERVER ONLY (imports node:fs)
import fs from "node:fs";
import path from "node:path";
import { articleFrontmatterSchema, type Pillar } from "./article-schema";
 
const ARTICLES_DIRECTORY = path.join(process.cwd(), "content/articles");
 
export function getAllArticles() {
  // reads content/articles off disk, parses + validates frontmatter —
  // never reaches the browser
}
lib/pillars.ts
// lib/pillars.ts  — server- AND client-safe (no fs, no Zod, no node imports)
export const PILLARS = [
  "running",
  "hiking",
  "lightweight-camping",
  "trail-dogs",
] as const;
 
export type Pillar = (typeof PILLARS)[number];
 
export function getPillarDisplayName(
  pillar: string,
  form: "short" | "long" = "long"
): string {
  // pure lookup — safe to import anywhere
}

Now the header — a Client Component — imports getPillarDisplayName from lib/pillars.ts, which has no fs anywhere in its import graph, so nothing server-only follows it into the browser. The file-reading code stays in lib/articles.ts, imported only by Server Components. The error is gone — not suppressed, gone, because the server-only code is no longer reachable from the client.

The same seam keeps Zod out too — and that's the real payoff

Here's what makes this more than an error fix. The pure module isn't just dodging fs — it's a deliberate dependency boundary that keeps Zod out of the client bundle as well.

The pillar list has to be the single source of truth for two very different consumers: the frontmatter validator (server-side, needs Zod to enforce the schema at build time) and the navigation (client-side, just needs to turn trail-dogs into "Trail Dogs"). So the canonical list lives in the Zod-free lib/pillars.ts, and the schema module re-exports it:

lib/article-schema.ts
// lib/article-schema.ts  — SERVER ONLY (imports zod)
import { z } from "zod";
import { PILLARS } from "./pillars";
 
// Re-exported for existing importers; the canonical definition lives in ./pillars.
export { PILLARS, type Pillar } from "./pillars";
 
export const articleFrontmatterSchema = z.object({
  // ...
  pillar: z.enum(PILLARS),
  // ...
});

So a Client Component that just needs a display name imports the pure module; a server-side validator imports the schema. Neither fs nor Zod follows the client into the browser bundle. The error forced the split — but the bundle-size win (Zod is not a small dependency to ship to every visitor who needs a category label) is the bonus that makes it worth doing deliberately, not just reactively.

The principle worth internalizing: keep pure helpers free of server-only imports, so they're safe to use from either side. A module's "client-safety" is determined by its entire import graph, not just its own top line — and the modules you keep pure become reusable everywhere for free. The same thin-boundary thinking shows up on the analytics side of this site too: the GA4 click event lives in a minimal client leaf that receives a pre-resolved href, so the registry and Zod stay on the server there as well.

The fix you'll see everywhere that you should skip

Search for this error, and the top answer is almost always this next.config webpack stub:

next.config.ts
// Don't reach for this first.
webpack: (config, { isServer }) => {
  if (!isServer) config.resolve.fallback = { fs: false };
  return config;
},

That tells webpack to resolve fs to nothing on the client, which makes the build error disappear. But it doesn't make your code correct — it just stops the bundler from complaining while it ships a broken reference to the browser. If your client code actually tries to call the fs-dependent function, it'll fail at runtime instead of build time, which is worse. The stub has a narrow legitimate use (a third-party package that references fs in a code path you never hit on the client), but for your own code, splitting the modules is the real fix. Reach for the stub only when you can't change the offending import — never as the first move.


The takeaway: this was never "fs is broken in Next." The bundler was showing me where the server/client seam belongs — and once the seam existed, it started paying for itself.

FAQ

Why does the error point at a component that doesn't use fs? Because imports are transitive. Your Client Component imported a module that, in turn, imported another module that imports fs. The browser bundle pulls in the whole chain, so the component named in the error is just the entry point, not the source.

Can't I just set fs: false in the webpack config? You can, and the build error goes away — but you've hidden the problem, not solved it. If client code ever calls the stubbed function it fails at runtime. Use the stub only for an unchangeable third-party import; split your own modules instead.

Does this apply to other Node built-ins like path or crypto? Yes. Any Node-only module (path, os, child_process, etc.) has the same constraint. The same fix applies: keep it in server-only modules and don't let Client Components import them, even indirectly.

Further reading

Keep reading

Newsletter

New posts in your inbox. No spam — unsubscribe anytime.

Occasional emails when we publish something worth your time. Unsubscribe anytime.

More about what you'll get on the newsletter page.

THEME
↑↓ navigate↵ openesc close