Module not found: Can't resolve 'fs' в Next.js — что не так
Эту ошибку получаешь, когда импортируешь 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— крайний случай, не дефолтный фикс.