0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · 강의

server-only와 client-only를 활용한 모듈 경계

서버 비밀과 무거운 코드가 클라이언트 번들로 유출되지 않도록 방지 패키지를 사용하는 방법을 배웁니다.

server-only와 client-only를 활용한 모듈 경계은(는) CoddyKit의 무료 Next.js 15 Fullstack (App Router + Server Actions) 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Next.js 15 Fullstack (App Router + Server Actions) 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

Why Module Boundaries Matter

Next.js 15 runs JavaScript in two distinct environments: the Node.js server and the browser client. Code you write in app/ can silently end up in either bundle depending on how it is imported.

Without explicit boundaries, a single careless import chain can expose:

  • Database credentials and API keys stored in environment variables
  • Server-only business logic that should never reach users
  • Heavy Node.js libraries (crypto, fs, net) that inflate the client bundle

The poison-pill packages server-only and client-only are the idiomatic Next.js solution. They throw a build-time error the moment a module crosses the wrong boundary — catching the mistake before any code ships to production.

Installing the Packages

Both packages are published by the Next.js team and carry zero runtime code. Their entire purpose is to act as a sentinel import that the bundler recognises.

Install them once in your project:

npm install server-only client-only

They are tiny — each package contains only a single index.js that throws an error if evaluated in the wrong environment. The build pipeline (via React's bundler conditions) ensures the error fires at compile time, not at runtime.

You do not need to configure anything in next.config.ts. The packages rely on the react-server export condition that Next.js sets automatically.

Marking a Module as Server-Only

Any file that reads server-side secrets, talks to a database, or uses Node.js built-ins should begin with a single import:

import 'server-only'

If a Client Component (or any client-side code) ever imports this module, Next.js will abort the build with a clear error message pointing to the offending import. The example below shows a data-access layer that must never reach the browser.

// lib/db.ts
import 'server-only'
import { Pool } from 'pg'

const pool = new Pool({
  connectionString: process.env.DATABASE_URL, // secret — server only
})

export async function getUserById(id: string) {
  const { rows } = await pool.query(
    'SELECT id, name, email FROM users WHERE id = $1',
    [id]
  )
  return rows[0] ?? null
}

What Happens When the Boundary Is Violated

Suppose a developer mistakenly imports the server-only data-access layer into a Client Component. Without the poison pill the secret DATABASE_URL would silently appear in the browser bundle.

With server-only in place, the Next.js build immediately stops and prints:

Error: This module cannot be imported from a Client Component module.
It should only be used from a Server Component.

This makes the violation impossible to ship accidentally. The error is deterministic — it fires on every next build and next dev hot-reload that crosses the boundary.

// app/dashboard/page.tsx — Server Component, safe to import lib/db
import { getUserById } from '@/lib/db'

export default async function DashboardPage() {
  const user = await getUserById('user_123')
  return <h1>Welcome, {user?.name}</h1>
}

// app/components/ProfileCard.tsx — Client Component
'use client'
// import { getUserById } from '@/lib/db'  // ← BUILD ERROR if uncommented
export function ProfileCard({ name }: { name: string }) {
  return <p>{name}</p>
}

Marking a Module as Client-Only

client-only solves the opposite problem. Some modules depend on browser-exclusive APIs like window, document, localStorage, or browser-specific third-party SDKs.

If such a module is imported into a Server Component, Node.js will throw at runtime because those browser globals do not exist on the server. client-only converts that runtime surprise into a build-time error.

Add the import at the top of any browser-exclusive utility:

import 'client-only'

// lib/analytics.ts
import 'client-only'

// This module calls browser APIs — it must never run on the server
export function trackEvent(name: string, props?: Record<string, unknown>) {
  if (typeof window === 'undefined') return // extra guard, but poison-pill fires first
  window.gtag?.('event', name, props)
}

export function getStoredUserId(): string | null {
  return localStorage.getItem('userId')
}

Safe Pattern: Server Data Passed as Props

The canonical Next.js 15 pattern is to fetch data in a Server Component using a server-only module, then pass safe, serialisable values down to Client Components as props.

No secrets, no database handles, no Node.js-only objects ever cross the network boundary — only plain data that can be safely serialised to JSON.

// lib/user-service.ts
import 'server-only'
import { pool } from './db'

export interface UserProfile {
  id: string
  name: string
  avatarUrl: string
}

export async function getProfile(userId: string): Promise<UserProfile | null> {
  const { rows } = await pool.query(
    'SELECT id, name, avatar_url FROM users WHERE id = $1',
    [userId]
  )
  if (!rows[0]) return null
  return { id: rows[0].id, name: rows[0].name, avatarUrl: rows[0].avatar_url }
}

// app/profile/page.tsx — Server Component
import { getProfile } from '@/lib/user-service'
import { AvatarCard } from '@/components/AvatarCard' // 'use client'

export default async function ProfilePage({ params }: { params: { id: string } }) {
  const profile = await getProfile(params.id)
  if (!profile) return <p>Not found</p>
  // Only serialisable data crosses to the client
  return <AvatarCard name={profile.name} avatarUrl={profile.avatarUrl} />
}

Server Actions Are Not a Bypass

Server Actions are functions that run on the server but are called from client-side code. A common misconception is that marking a function with 'use server' automatically protects the entire module.

It does not. The 'use server' directive only tells Next.js to expose that function as an HTTP endpoint. Other exports in the same file could still leak if imported directly.

Best practice: keep Server Actions in dedicated actions/ files and still guard shared server utilities with server-only.

// app/actions/update-profile.ts
'use server'
import { getUserById } from '@/lib/db' // lib/db has 'server-only' — safe
import { revalidatePath } from 'next/cache'

export async function updateProfileAction(formData: FormData) {
  const name = formData.get('name') as string
  const userId = formData.get('userId') as string

  // Business logic runs entirely on the server
  const existing = await getUserById(userId)
  if (!existing) throw new Error('User not found')

  // db update omitted for brevity
  revalidatePath('/profile')
}

// app/profile/edit/page.tsx — this is a Client Component form
'use client'
import { updateProfileAction } from '@/app/actions/update-profile'

export function EditForm({ userId }: { userId: string }) {
  return (
    <form action={updateProfileAction}>
      <input type="hidden" name="userId" value={userId} />
      <input name="name" placeholder="New name" />
      <button type="submit">Save</button>
    </form>
  )
}

Organising Your lib/ Folder by Boundary

A predictable folder convention eliminates guesswork about which environment a utility targets. A widely-adopted layout looks like this:

  • lib/server/ — every file starts with import 'server-only'
  • lib/client/ — every file starts with import 'client-only'
  • lib/shared/ — pure functions with no environment-specific imports (safe in both)

With this structure, a code reviewer can immediately tell whether a new utility is correctly placed before even reading its contents. CI linting rules can also enforce that files inside lib/server/ contain the sentinel import.

// lib/shared/format.ts — no sentinel needed, works anywhere
export function formatCurrency(amount: number, currency = 'USD'): string {
  return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
}

// lib/server/stripe.ts
import 'server-only'
import Stripe from 'stripe'

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-11-20.acacia',
})

// lib/client/toast.ts
import 'client-only'
import { toast } from 'sonner'

export function showSuccess(msg: string) {
  toast.success(msg)
}

Combining with TypeScript Path Aliases

TypeScript path aliases in tsconfig.json make boundary-aware imports ergonomic and consistent across the codebase. You can encode the boundary directly in the alias name, making imports self-documenting.

// tsconfig.json (relevant excerpt)
// {
//   "compilerOptions": {
//     "paths": {
//       "@server/*": ["./lib/server/*"],
//       "@client/*": ["./lib/client/*"],
//       "@shared/*": ["./lib/shared/*"]
//     }
//   }
// }

// Usage in a Server Component:
import { stripe } from '@server/stripe'
import { formatCurrency } from '@shared/format'

// Usage in a Client Component:
import { showSuccess } from '@client/toast'
import { formatCurrency } from '@shared/format'

// Attempting to import @server/* in a Client Component
// triggers the server-only build error immediately.

Third-Party Packages Without Directives

Many npm packages (especially older ones) include no 'use client' or 'use server' directive. The Next.js bundler applies a heuristic: if a package has no directive it is treated as shared — eligible for both environments.

This can be a problem when a package uses browser globals internally. The solution is to wrap it in your own lib/client/ module marked with client-only, so the import chain is explicit and auditable.

// lib/client/chart-wrapper.ts
import 'client-only'
// chart.js has no 'use client' directive but uses window internally
import { Chart, registerables } from 'chart.js'

Chart.register(...registerables)

export { Chart }
export type { ChartConfiguration } from 'chart.js'

// components/RevenueChart.tsx
'use client'
import { Chart } from '@client/chart-wrapper' // boundary enforced
import { useEffect, useRef } from 'react'

export function RevenueChart({ data }: { data: number[] }) {
  const ref = useRef<HTMLCanvasElement>(null)
  useEffect(() => {
    if (!ref.current) return
    const ctx = ref.current.getContext('2d')!
    new Chart(ctx, { type: 'line', data: { labels: data.map(String), datasets: [{ data }] } })
  }, [data])
  return <canvas ref={ref} />
}

Verifying Your Bundles with @next/bundle-analyzer

Poison-pill packages prevent accidental leakage at build time, but it is still valuable to verify your bundles visually. The @next/bundle-analyzer package generates an interactive treemap of every module in each bundle.

Use it to confirm that server-only modules (database clients, Stripe SDKs, heavy Node.js libs) are absent from the client chunk and vice versa. If a secret-containing module appears in the client bundle despite the sentinel import, it usually means a dynamic import or a re-export bypassed the check.

// next.config.ts
import type { NextConfig } from 'next'
import withBundleAnalyzer from '@next/bundle-analyzer'

const withAnalyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})

const nextConfig: NextConfig = {
  // your existing config
}

export default withAnalyzer(nextConfig)

// Run analysis:
// ANALYZE=true next build
// Opens two browser tabs:
//   - client.html  (browser bundle — check for leaked server deps)
//   - server.html  (server bundle)

Knowledge Check: server-only vs client-only

Choose the best answer to the question below.

Recap: Module Boundaries with server-only and client-only

In this lesson you learned how to enforce hard module boundaries in a Next.js 15 application:

  • import 'server-only' — turns a module into a build-time error if imported from any client-side code. Use it on database clients, secret-reading utilities, and Node.js-exclusive code.
  • import 'client-only' — mirrors the pattern for browser-exclusive modules, catching server-side imports before they cause runtime crashes.
  • The safe data-flow pattern: fetch in a Server Component using server-only utilities, then pass serialisable props to Client Components — no secrets, no Node APIs cross the wire.
  • Organise lib/ into server/, client/, and shared/ sub-directories, and align TypeScript path aliases to make boundaries self-documenting.
  • Wrap third-party packages that lack directives inside your own boundary-marked modules to keep the import graph auditable.
  • Combine poison-pill packages with @next/bundle-analyzer to visually verify that no server-only dependencies appear in client chunks.

These two packages cost almost nothing to add and eliminate an entire class of security and performance bugs before they ever reach production.

자주 묻는 질문

“server-only와 client-only를 활용한 모듈 경계” 강의는 무료인가요?

네 — “server-only와 client-only를 활용한 모듈 경계” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Next.js 15 Fullstack (App Router + Server Actions) 강의 전체를 잠금 해제할 수 있습니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.

“server-only와 client-only를 활용한 모듈 경계”에서 뭘 배우나요?

서버 비밀과 무거운 코드가 클라이언트 번들로 유출되지 않도록 방지 패키지를 사용하는 방법을 배웁니다. 브라우저에서 직접 실행하는 실습 코드로 Next.js 15 Fullstack (App Router + Server Actions)을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

Next.js 15 Fullstack (App Router + Server Actions)을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 Next.js 15 Fullstack (App Router + Server Actions)은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.

“server-only와 client-only를 활용한 모듈 경계” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 Next.js 15 Fullstack (App Router + Server Actions) 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 Next.js 15 Fullstack (App Router + Server Actions) 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 클라이언트 번들 분석과 축소
  2. Turbopack과 컴파일러 설정 심층 분석
  3. server-only와 client-only를 활용한 모듈 경계
  4. 동적 가져오기, 코드 분할과 지연 하이드레이션
← Next.js 15 Fullstack (App Router + Server Actions)(으)로 돌아가기