A Server Component can safely import a module that holds a database client, an API key, or other server-only logic, because nothing in that file ever reaches the browser. The risk is that JavaScript imports do not enforce that rule on their own. If a Client Component later imports the same module, directly or through a shared file, its code joins the client bundle exactly like any other import.
This is not a hypothetical edge case. Utility files get reused across a codebase, and a module that started out server-only can end up imported from a shared file that a Client Component also touches, weeks after the original author moved on.
// lib/db.ts
import { Client } from 'pg'
export async function getUser(id: string) {
const client = new Client({ connectionString: process.env.DATABASE_URL })
await client.connect()
const result = await client.query('SELECT * FROM users WHERE id = $1', [id])
await client.end()
return result.rows[0]
}This file runs only on the server today, purely because only one page happens to import it right now. Nothing in the file itself marks that boundary, so any future import from a different kind of file changes that behavior without warning.
// app/dashboard/page.tsx
import { getUser } from '@/lib/db'
export default async function DashboardPage() {
const user = await getUser('current')
return <h1>Welcome, {user.name}</h1>
}This page component has no client directive, so it is a Server Component that runs entirely on the server. The database call, the query text, and the connection string all stay out of anything sent to the browser for this route.
How the same module leaks into the client
A teammate later wants to show a name without waiting on a server round trip, so they import the same data function from a component that already needs interactivity. The import graph pulls the whole module along with it, connection logic and all.
// app/ui/user-badge.tsx
'use client'
import { useEffect, useState } from 'react'
import { getUser } from '@/lib/db'
export default function UserBadge() {
const [name, setName] = useState('')
useEffect(() => {
getUser('current').then((u) => setName(u.name))
}, [])
return <span>{name}</span>
}This component runs on both the server, for the first render, and the browser, after hydration, because it carries a client directive. That directive is also why the database module now ships to the browser: the connection setup, the SQL query shape, and any hardcoded value inside that file all become part of the JavaScript every visitor downloads, whether or not the function ever runs there successfully.
The safeguard: the server-only package
The server-only package marks a module as server-only so a build fails immediately instead of shipping a silent leak. Install it, then add one import line above every other import in a file that should never run in the browser.
npm install server-onlyThe package has no runtime behavior on its own. Next.js recognizes the import specially and fails the build whenever that module ends up inside a Client Component's module graph, which is what turns a silent mistake into an immediate one. Installing the package is optional, since Next.js already tracks this internally, but adding it to the project's dependencies keeps a linter from flagging the import as an unresolved module.
Before and after: guarding a database utility
Here is the same database file with the guard added. The only change is import 'server-only', placed before the other imports.
// lib/db.ts
import 'server-only'
import { Client } from 'pg'
export async function getUser(id: string) {
const client = new Client({ connectionString: process.env.DATABASE_URL })
await client.connect()
const result = await client.query('SELECT * FROM users WHERE id = $1', [id])
await client.end()
return result.rows[0]
}The dashboard page still works exactly as before, since a Server Component is allowed to import a guarded module without restriction. The difference shows up the moment the client badge component from the earlier example tries to import that same function again.
This module cannot be imported from a Client Component module.
It should only be used from a Server Component.The build fails on that import line instead of quietly bundling the database logic into every page that loads the badge. The fix is to move the data call into a Server Component and pass the finished result down as a prop, instead of importing the module from client code at all.
For that composition shape, see Server Components vs Client Components in Next.js and Data Access Layers and Keeping Secrets Off the Client.
The reverse guard for browser-only code
A companion package exists for the opposite mistake, a module that only makes sense in a browser, such as one that reads the window object directly. Adding an equivalent import to the top of that file turns an accidental Server Component import into the same kind of build failure, instead of a runtime error discovered later. See Why window and localStorage Do Not Exist in Server Components for what that failure looks like without any guard in place.
| Guard | Protects | Import line |
|---|---|---|
| server-only | A server module reaching the client bundle | import 'server-only' |
| client-only | A browser-only module reaching a Server Component | import 'client-only' |
Both packages are optional, and Next.js already separates the two environments on its own. What they add is a build failure you see immediately, instead of a bundle audit weeks later.
The deliberate way to expose a value
Not every value needs to stay on the server. Prefixing an environment variable with NEXT_PUBLIC_ tells Next.js to inline that value into the client bundle on purpose, at build time, while an unprefixed variable stays server-only by default and never gets that treatment.
Treat that prefix as the one intentional door between the two environments. If a value should never appear in a browser's network tab or page source, it should never carry that prefix. A server-only guard and an unprefixed variable solve the same problem from two directions, one blocking the import and the other blocking the value, and using both together is the safer default for any module handling credentials.
See Next.js Environment Variables and NEXT_PUBLIC_ for the full rules on build-time inlining and where each kind of variable is available.
Common mistakes
Adding the guard only after a leak already shipped protects the next build, not the one already in production, so add it as soon as a module touches a secret or a database rather than after an incident. Watch for indirect imports too, where a guarded module gets re-exported from a shared file that a Client Component also imports from, since the failure still triggers but can be harder to trace back to its source.
The guard can only see the import graph, so it will not catch a secret that gets passed as a prop into a Client Component instead of being imported directly. That is a different failure mode with its own dedicated tooling, worth knowing about once the basic import mistake is under control.
Rune AI
Key Insights
- A module is safe only because of who imports it today, not because of what it contains.
- A Client Component that imports a server-only module pulls that module's code into the browser bundle.
- Unprefixed environment variables become an empty string on the client, so the code breaks silently instead of leaking the value.
- The server-only package makes that same mistake fail the build instead of shipping broken or unsafe code.
- A companion package guards the reverse case, browser-only modules that a Server Component should never import.
- Reserve NEXT_PUBLIC_ prefixed variables only for values that are genuinely safe to expose.
Frequently Asked Questions
Does installing server-only actually block secrets from reaching the browser, or is it just a warning?
If I forget the guard, will my API key definitely show up in the browser?
Is server-only required to protect secrets in Next.js?
Is there a package for the opposite mistake, guarding browser-only code?
Conclusion
JavaScript imports do not know about the Next.js server and client split on their own, so a module with secrets or database logic can drift into a Client Component's bundle without anyone noticing. Guard every server-only module with one import line, and let the build fail loudly instead of shipping quietly.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.