PostgreSQL

Fix Can't resolve 'fs' When Importing pg in Next.js

Where a node-postgres Pool belongs in a Next.js app, why a Client Component import breaks the build, and how to prove one pool serves every request.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
9 min read

TL;DR#

Module not found: Can't resolve 'fs' after import { Pool } from 'pg' means the file that imports pg is in the browser bundle. node-postgres needs Node.js modules (fs, dns, net, tls) that do not exist in a browser. Put the pool in one module, lib/db.js, create it once at module scope, and import that module only from Server Components, Route Handlers, getServerSideProps or API routes. In an App Router project, start that module with import 'server-only'; leave it out if a Pages Router page imports the module, because that build rejects it. Client Components receive the rows as props or fetch them from a Route Handler.

The error#

The Stack Overflow question follows the pg-pool README's advice to create one long-lived pool, puts it in my-app/lib/db.js, and gets this as soon as import { Pool } from 'pg'; runs through the build. The same output comes from Next.js 16.4.0 when it is forced onto webpack (next build --webpack) and a page marked 'use client' imports lib/db.js:

text
./node_modules/pg-connection-string/index.js
Module not found: Can't resolve 'fs'
 
https://nextjs.org/docs/messages/module-not-found
 
Import trace for requested module:
./node_modules/pg/lib/connection-parameters.js
./node_modules/pg/lib/client.js
./node_modules/pg/lib/index.js
./node_modules/pg/esm/index.mjs
./lib/db.js
./app/page.js

With the default bundler, Turbopack, the same files produce a longer report that starts with a different module:

text
Error: Turbopack build failed with 7 errors:
./node_modules/pg/lib/connection-parameters.js:3:13
Error: Module not found: Can't resolve 'dns'

The seven errors are dns, fs twice (once from pg-connection-string, once from pgpass), net twice, tls, and util/types. Whichever name you see first, the cause is the same.

Why it happens#

Next.js compiles a module separately for each environment that imports it. The Server and Client Boundary guide puts it this way: "Next.js compiles a module used by both graphs separately for each environment." A file marked 'use client', and everything it imports, goes into the client graph, which is shipped to the browser.

node-postgres is not written for that graph. Its connection code requires Node.js built-ins: const dns = require('dns') in pg/lib/connection-parameters.js, require('net') and require('tls') in pg/lib/stream.js, and require('fs') both in pgpass, which pg/lib/client.js loads to read a password file, and in pg-connection-string, for SSL certificate files. Some of these calls would rarely run: the one in pg-connection-string only loads fs when an SSL certificate path is configured. The build flagged it anyway, at ./node_modules/pg-connection-string/index.js:88:70, so the bundler resolves the call whether or not it would run. A browser has none of these modules, so the build fails. The Turbopack trace labels each step of the chain [Client Component Browser], ending at ./app/page.js, which tells you which file dragged pg into the browser.

The server side was never the problem. pg is on the list of packages that Next.js keeps out of its server bundles and loads with native Node.js require, so adding it to serverExternalPackages changes nothing here.

The accepted answer on the question is about the Pages Router, and it holds on Next.js 16. The getServerSideProps reference says: "You can import modules in top-level scope for use in getServerSideProps. Imports used will not be bundled for the client-side." The key word is used. An import that only getServerSideProps touches is removed from the page's browser code; one reference from the page component keeps it in, and the fs error comes back.

Fix#

1. Give the pool its own server-only module#

js
// lib/db.js
import 'server-only'
import { Pool } from 'pg'
 
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })

The pool is created at module scope, so a Node.js process evaluates it once and every import receives the same object. That is what the pg-pool README asks for: "instantiate one pool when your app starts up and use the same instance of the pool throughout the lifetime of your application."

The first line is optional but worth having. The Next.js docs on preventing environment poisoning say that with import 'server-only' in a file, "if you try to import the module into a Client Component, there will be a build-time error", and that Next.js handles the import internally, so installing the package is optional. The reproduction below ran without installing it. It is an App Router marker, though: step 3 shows what happens when a Pages Router page imports the same file.

2. Query from a Server Component#

In the App Router, components are Server Components unless their file, or a file that imports them, is marked 'use client'. Remove that directive from the page that reads the database:

jsx
// app/page.js
import { connection } from 'next/server'
import { pool } from '../lib/db'
 
export default async function Page() {
  await connection()
  const { rows } = await pool.query('select now() as now')
  return <p>{String(rows[0].now)}</p>
}

await connection() is there because of what the build does without it. With the same page minus that line, next build prerendered / as static content and ran the query once, at build time. The connection() reference describes it as the way to say "rendering should wait for an incoming user request before continuing". A page that already reads a request-time API, such as cookies or headers, does not need it; if build-time data is what you want, leave it out.

3. In the Pages Router, keep the pool inside getServerSideProps#

jsx
// pages/legacy.js
import { pool } from '../lib/db'
 
export async function getServerSideProps() {
  const { rows } = await pool.query('select now() as now')
  return { props: { now: String(rows[0].now) } }
}
 
export default function Legacy({ now }) {
  return <p>{now}</p>
}

Here lib/db.js must not start with import 'server-only'. With the marker in place and this page importing the file, the build failed with a single error, its import trace ending at ./pages/legacy.js:

text
Error: Turbopack build failed with 1 error:
./lib/db.js:1:1
Error: You're importing a module that depends on "server-only". This API is only available in Server Components in the App Router, but you are using it in the Pages Router.

API routes are different. With the page removed and a pages/api/now.js handler importing the same server-only module, the build passed. So a mixed project can keep the marker as long as no file in pages/ other than API routes imports the pool; otherwise use a plain lib/db.js and keep every client file away from it (step 4).

4. Give Client Components data, not the pool#

A component that needs useState or event handlers stays a Client Component and receives the rows as props from a Server Component parent. If it must load data after an interaction, it calls a Route Handler (app/api/.../route.js) that imports the pool. The client-side data fetching guide covers both patterns.

Verify the fix#

All of this was reproduced on 11 October 2026 in Docker: Next.js 16.4.0, pg 8.23.1, React 19.3.0 and Node v24.21.0 (node:24-bookworm image), against PostgreSQL 18.6 (postgres:18 image).

First, the build passes and the page is rendered per request:

text
Route (app)
┌ ƒ /

Second, the pool is actually shared. After next start, 20 sequential requests all returned 200, and PostgreSQL counted one backend for the app's database (pg_stat_activity filtered on the database name):

text
 backends_for_shop 
-------------------
                 1

Concurrent requests can open more than one, because each query in flight checks out its own client, up to the pool's max, which the pool documentation sets to 10 by default. In a re-run on 15 October 2026, a burst of 20 concurrent requests counted 1 backend while the pool still held an idle client, and 10 once that client had timed out. Either way the count stops at max; it does not grow with the number of requests.

Then a negative control: the same page with const pool = new Pool(...) moved inside the page function, which is the pattern the pg-pool README warns about. After 20 requests, PostgreSQL counted 21 backends for the app's database; the 15 October re-run, which sent no other request to the page, counted exactly 20:

text
 backends_for_shop 
-------------------
                21

About one connection per request, each held open until the pool's idle timeout closes it. The pool documentation sets idleTimeoutMillis to 10 seconds by default, and 12 seconds later the count was back to 0. On a busy app those connections pile up against the server's limit, which is the failure the Supabase pooling guide deals with on serverless platforms.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

What server-only adds to the error output#

With import 'server-only' in lib/db.js and the 'use client' page still importing it, Turbopack reports 9 errors instead of 7. The two new ones both point at ./lib/db.js:1:1, your file, instead of node_modules. The second is the clear one:

text
Error: 'server-only' cannot be imported from a Client Component module
It should only be used from a Server Component.

The first is the message from step 3, and here it is misleading: the page is in app/, yet it says "but you are using it in the Pages Router". Read both as "a client file imports lib/db.js" and follow the import trace below them. The seven Can't resolve errors are still listed after these two; they disappear once the client import is gone.

The Pages Router rule was also tested. With the pool used only in getServerSideProps, the build passed and listed /legacy as a dynamic page. Adding one line to the component body, const max = pool.options.max, brought back all seven Can't resolve errors, with ./pages/legacy.js at the end of each browser import trace.

The Middleware detour in the question#

The question links an answer saying Node.js libraries that use fs cannot run in Middleware. In February 2022 Middleware had no Node.js runtime option. The version history in the Proxy reference adds it as experimental in v15.2.0 and stable in v15.5.0, and since v16.0.0 Middleware is renamed Proxy and "Proxy defaults to using the Node.js runtime". Even so, initialising a database pool is not Proxy's job: the pool belongs in the module from step 1, imported where queries run.

If the error changes after the fix#

Moving the query into a Server Component moves where it runs, and a failure can move with it. If the page is prerendered and the build host cannot reach the database, next build now fails during prerendering instead of bundling. In the reproduction, pointing DATABASE_URL at 127.0.0.1 inside the app container gave:

text
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
Error: connect ECONNREFUSED 127.0.0.1:5432

Either make the page request-time (step 2) or give the build a reachable host. In Docker Compose, 127.0.0.1 is the app container itself: ECONNREFUSED 127.0.0.1:5432 in Docker Compose explains why and how to use the service name instead. The same error in a CI build with a Postgres service container is covered in ECONNREFUSED 127.0.0.1:5432 in GitHub Actions. If the connection gets through and the server then refuses the credentials, continue with password authentication failed for user "postgres".

For Can't resolve errors from other packages, such as path aliases or letter case that only break on deploy, see Next.js "Module not found: Can't resolve". The boundary rules themselves, and why 'use server' is not the opposite of 'use client', are in use client vs use server.

The question asked where a pool goes in the Next.js architecture, and the answer is narrower than "on server start": in one module that only server code imports. Next.js already loads pg natively on the server; your only job is to make sure no file on the browser side imports it, and in the App Router server-only turns a slip into an error that names the file.

Further reading: Fix NeonDbError: Error connecting to database: fetch failed.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote