lenec ru

← все посты

Module not found: Can't resolve 'fs' в Next.js — что не так

13K

Эту ошибку получаешь, когда импортируешь Node-модуль (fs, path, net, crypto) в код, который собирается под браузер. У бандлера webpack нет браузерной реализации fs, и он честно говорит «не могу разрезолвить».

Module not found: Can't resolve 'fs'

Import trace for requested module:
./node_modules/some-lib/dist/index.js
./components/Editor.tsx

В Next.js это обычно означает одно из трёх: ты случайно импортируешь серверный модуль из клиентского компонента, либо тащишь зависимость, которая работает только в Node, либо в импорте остался серверный код, который Next пытается уволочь в клиент-бандл.

Главное: разобраться, где и какой код выполняется

В App Router логика чёткая:

  • файлы без 'use client' — серверные. Им можно fs, path, обращения к БД, секреты.
  • файлы с 'use client' вверху — клиентские. Они уйдут в бандл и попадут в браузер. Здесь fs запрещён.

В Pages Router всё чуть сложнее: код страниц рендерится и на сервере, и на клиенте, но Next из коробки умеет вытаскивать импорты, которые используются только в getServerSideProps/getStaticProps, и не тащить их в клиент. То же касается API-ручек.

Если ты получил Can't resolve 'fs', значит цепочка импортов привела серверный модуль в клиентскую часть. Дальше нужно сломать эту цепочку.

Сценарий 1: серверный код в клиентском компоненте

Самый частый кейс. Клиентский компонент сверху импортирует серверную утилиту, в которой есть fs:

// app/posts/PostList.tsx
'use client';

import { readPostsFromDisk } from '@/lib/posts';

export function PostList() {
  const posts = readPostsFromDisk();
  return <ul>{posts.map((p) => <li key={p.slug}>{p.title}</li>)}</ul>;
}

В lib/posts сидит import { readFileSync } from 'fs' — и Next пытается засунуть всё это в браузер.

Правильный путь — оставить чтение с диска на сервере, а клиенту отдать готовые данные через пропсы:

// app/posts/page.tsx — серверный по умолчанию
import { readPostsFromDisk } from '@/lib/posts';
import { PostList } from './PostList';

export default async function PostsPage() {
  const posts = await readPostsFromDisk();
  return <PostList posts={posts} />;
}
// app/posts/PostList.tsx
'use client';

export function PostList({ posts }: { posts: { slug: string; title: string }[] }) {
  return <ul>{posts.map((p) => <li key={p.slug}>{p.title}</li>)}</ul>;
}

Сценарий 2: барели тянут лишнее

У тебя есть lib/index.ts, который реэкспортирует всё подряд — и серверное, и клиентское. Ты импортируешь оттуда одну функцию для клиента, а вместе с ней едут все серверные импорты, потому что бандлер не всегда может срубить tree-shake.

Решение — разделить клиентское и серверное по разным файлам и импортировать напрямую:

// плохо
import { formatPrice } from '@/lib';

// хорошо
import { formatPrice } from '@/lib/format-price';

Барели — удобный сахар, но именно с ними чаще всего прилетает Can't resolve 'fs' из неожиданных мест.

Сценарий 3: пакет, который изоморфен на словах

Бывает, что библиотека на бумаге работает и на клиенте, и на сервере, но условные импорты внутри неё неправильно разрезолвятся бандлером.

В Pages Router у меня выручал такой трюк в next.config.js:

/** @type {import('next').NextConfig} */
module.exports = {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.resolve.fallback = {
        ...config.resolve.fallback,
        fs: false,
        path: false,
        net: false,
      };
    }
    return config;
  },
};

Это говорит бандлеру: «при сборке для клиента считай fs пустым модулем». Сработает только если ветка кода с fs не выполнится в браузере на самом деле — иначе ты просто превратил compile-time ошибку в runtime.

Этот фикс — последний рубеж, не первый. Сначала ищи, где импорт реально нужен на клиенте, а где можно отрезать.

Сценарий 4: Server Actions и server-only

В App Router есть удобный пакет server-only. Импортируешь его в начале серверной утилиты — и Next упадёт на этапе сборки, если кто-то попробует утащить эту утилиту в клиентский компонент:

// lib/db.ts
import 'server-only';
import { sql } from '@/lib/sql';

export async function getUser(id: string) {
  return sql`SELECT * FROM users WHERE id = ${id}`;
}

Это не лечит существующую ошибку, но защищает от появления новых. Я обычно ставлю его во все файлы, которые трогают БД, файловую систему или секреты.

Сценарий 5: динамический импорт

Если функция нужна только на сервере и только иногда, можно явно отгородить её динамическим импортом:

if (typeof window === 'undefined') {
  const fs = await import('fs/promises');
  // ...
}

В Next App Router этот трюк всё равно не нужен, потому что серверные компоненты сами по себе работают только на сервере. Но в библиотеках, которые хотят оставаться универсальными, такой подход иногда оправдан.

Как я ищу источник проблемы

Сама ошибка обычно даёт цепочку импортов. Вот реальный пример:

Import trace for requested module:
./node_modules/markdown-magic/src/index.js
./lib/render-markdown.ts
./components/Article.tsx

Это значит, что Article.tsx (клиентский) тащит render-markdown, который тащит библиотеку с fs внутри. Решение очевидно — рендерить markdown на сервере и отдавать в клиентский Article уже готовый HTML или React-узлы.

Что забрать с собой

  • Can't resolve 'fs' — это сигнал, что бандлер тащит серверный модуль в клиент.
  • Лечится не настройками webpack, а архитектурой: чтение с диска делается в server-компонентах, а клиентским передаются уже готовые данные.
  • Барели — частый источник лишних импортов; точечный импорт надёжнее.
  • server-only в каждом серверном модуле — отличная страховка.
  • resolve.fallback с fs: false — крайний случай, не дефолтный фикс.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.