Vercel Logo

Server and Client Components

You added a useState to track a counter. The page crashes. "useState is not defined." You didn't change anything else. What happened?

In the App Router, all components are Server Components by default. Server Components run on the server where there's no useState, no onClick, no browser. When you need interactivity, you opt into Client Components with the 'use client' directive.

This lesson shows you exactly when each type applies and how to compose them together.

Outcome

A working demo showing environment variable access differences between Server and Client Components, plus an interactive counter that demonstrates the 'use client' boundary.

Fast Track

  1. Visit http://localhost:3000/env-demo to see the existing Server/Client component split
  2. Add useState to the Client Component to make it interactive
  3. Verify that server-only env vars show undefined on the client

The Decision Model

┌─────────────────────────────────────────────────────────────┐
│                    Need This?                               │
├─────────────────────────────────────────────────────────────┤
│  useState, useEffect, useContext          → Client          │
│  onClick, onChange, onSubmit              → Client          │
│  Browser APIs (localStorage, window)      → Client          │
│  Third-party libs needing browser         → Client          │
├─────────────────────────────────────────────────────────────┤
│  Direct database/file access              → Server          │
│  Secret env vars (API keys, tokens)       → Server          │
│  Heavy dependencies (keep off client bundle) → Server       │
│  SEO-critical content                     → Server          │
│  Everything else                          → Server (default)│
└─────────────────────────────────────────────────────────────┘

Rule of thumb: Start with Server Components. Add 'use client' only when you need interactivity or browser APIs.

How It Works

When you add 'use client' at the top of a file, you're declaring a boundary. Everything in that file and everything it imports becomes part of the client bundle (the JavaScript files sent to and executed in the browser). This is why you want to keep client boundaries as small as possible.

apps/web/src/components/counter.tsx
'use client'
 
import { useState } from 'react'
 
export function Counter() {
  const [count, setCount] = useState(0)
  
  return (
    <button
      type="button"
      onClick={() => setCount(count + 1)}
      className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
    >
      Count: {count}
    </button>
  )
}

Without 'use client', this component would error because useState doesn't exist on the server.

The Boundary, Not the Component

'use client' marks the entry point into client code. You don't need it in every file that uses hooks, only in files that are directly imported by Server Components. Child components of a Client Component are automatically client-side.

Environment Variables: The Security Boundary

Your environment variables were set up in Project Setup via vercel link. The starter already has two components that demonstrate the Server/Client split:

apps/web/src/components/server-env-display.tsx
export function ServerEnvDisplay() {
  return (
    <div className="rounded border p-4">
      <h3 className="font-bold">Server Component</h3>
      <p>Public: {process.env.NEXT_PUBLIC_APP_NAME}</p>
      <p>Server-only: {process.env.INTERNAL_CONFIG}</p>
    </div>
  )
}
apps/web/src/components/client-env-display.tsx
'use client'
 
export function ClientEnvDisplay() {
  return (
    <div className="rounded border p-4">
      <h3 className="font-bold">Client Component</h3>
      <p>Public: {process.env.NEXT_PUBLIC_APP_NAME}</p>
      <p>Server-only: {process.env.INTERNAL_CONFIG || 'undefined'}</p>
    </div>
  )
}

Key difference:

  • NEXT_PUBLIC_* variables are inlined (embedded directly as static values) into the client bundle at build time, so both components can access them
  • Non-prefixed variables (INTERNAL_CONFIG) exist only in the Node.js environment, invisible to the browser
Security Implication

Never put secrets (API keys, database URLs, tokens) in NEXT_PUBLIC_* variables. They're visible in browser dev tools and your JavaScript bundle.

Step 1: Add Interactivity to the Client Component

Update the Client Component to include a counter, demonstrating why 'use client' is necessary:

apps/web/src/components/client-env-display.tsx
'use client'
 
import { useState } from 'react'
 
export function ClientEnvDisplay() {
  const [clicks, setClicks] = useState(0)
  
  const handleClick = () => setClicks(clicks + 1)
  
  return (
    <div className="rounded border p-4">
      <h3 className="font-bold">Client Component</h3>
      <p>Public: {process.env.NEXT_PUBLIC_APP_NAME}</p>
      <p>Server-only: {process.env.INTERNAL_CONFIG || 'undefined'}</p>
      <button
        type="button"
        onClick={handleClick}
        className="mt-2 rounded bg-blue-500 px-3 py-1 text-white hover:bg-blue-600"
      >
        Clicked {clicks} times
      </button>
    </div>
  )
}

Step 2: Test the Difference

Navigate to http://localhost:3000/env-demo. You should see:

Environment Variable Demo

┌──────────────────────────────────────┐
│ Server Component                     │
│ Public: ACME Corporation             │
│ Server-only: server-only-value       │
└──────────────────────────────────────┘

┌──────────────────────────────────────┐
│ Client Component                     │
│ Public: ACME Corporation             │
│ Server-only: undefined               │
│ [Clicked 0 times]                    │
└──────────────────────────────────────┘

Click the button. The count increases. This interactivity is only possible because of 'use client'.

Now open browser DevTools (F12) → Network tab → refresh the page. Look at the HTML response. The Server Component's INTERNAL_CONFIG value appears in the initial HTML (rendered on the server). The Client Component's value shows undefined because that code runs in the browser.

Composition Pattern: Server Inside Client

What if you need a Client Component wrapper (for interactivity) but want to keep some content server-rendered?

Pass Server Components as children. The Server Component renders on the server, then gets passed to the Client Component as already-rendered content.

Create a collapsible wrapper that needs client-side state:

apps/web/src/components/collapsible.tsx
'use client'
 
import { useState, type ReactNode } from 'react'
 
export function Collapsible({ 
  title, 
  children 
}: { 
  title: string
  children: ReactNode 
}) {
  const [isOpen, setIsOpen] = useState(true)
  
  return (
    <div className="rounded border">
      <button
        type="button"
        onClick={() => setIsOpen(!isOpen)}
        className="flex w-full items-center justify-between p-4 text-left font-semibold hover:bg-gray-50"
      >
        {title}
        <span>{isOpen ? '−' : '+'}</span>
      </button>
      {isOpen && <div className="border-t p-4">{children}</div>}
    </div>
  )
}

Now use it in the env-demo page, passing the Server Component as children:

apps/web/src/app/env-demo/page.tsx
import { ServerEnvDisplay } from '@/components/server-env-display'
import { Collapsible } from '@/components/collapsible'
import { ClientEnvDisplay } from '@/components/client-env-display'
 
export default function EnvDemoPage() {
  return (
    <main className="flex flex-col gap-4 p-4">
      <h1 className="text-2xl font-bold">Environment Variable Demo</h1>
      <Collapsible title="Server-Rendered Content">
        <ServerEnvDisplay />
      </Collapsible>
      <ClientEnvDisplay />
    </main>
  )
}

The Collapsible wrapper is a Client Component (needs useState for toggle), but ServerEnvDisplay inside it is still server-rendered. The children are rendered on the server first, then passed as props.

Why This Matters

This pattern keeps your client bundle small. The ServerEnvDisplay code never ships to the browser. Only the rendered HTML output does. For components with heavy dependencies or sensitive logic, this is significant.

Try It

  1. Environment variable visibility:

    • Server Component shows both NEXT_PUBLIC_APP_NAME and INTERNAL_CONFIG values
    • Client Component shows NEXT_PUBLIC_APP_NAME but INTERNAL_CONFIG is undefined
  2. Interactivity works:

    • Click the "Clicked X times" button
    • Counter increments on each click
  3. Composition works:

    • Click the "Server-Rendered Content" header to collapse/expand
    • The Server Component content toggles visibility
    • Check View Source (Ctrl+U): server-only-value appears in the initial HTML

Commit

git add -A
git commit -m "feat: demonstrate server/client component boundaries"
git push

Done-When

  • http://localhost:3000/env-demo shows both Server and Client components
  • Server Component displays INTERNAL_CONFIG value; Client Component shows undefined
  • Clicking the button increments the counter (proves useState works)
  • Collapsible wrapper toggles Server Component visibility
  • View Source (Ctrl+U) shows server-only-value in initial HTML

Troubleshooting

useState is not defined
Error: useState is not a function

You're using a hook in a Server Component. Add 'use client' at the top of the file:

'use client'
 
import { useState } from 'react'
Environment variables show undefined
  1. Run vercel env pull from apps/web/ to pull env vars from your Vercel project
  2. Make sure .env.local is in apps/web/, not the repo root
  3. Restart the dev server after pulling env vars
  4. NEXT_PUBLIC_* vars are inlined at build time, so changes require a restart
Hydration mismatch error
Error: Hydration failed because the server rendered content doesn't match the client

This happens when server and client render different content. Common causes:

  • Using Date.now() or Math.random() without proper handling
  • Browser-only values like window.innerWidth without checking

For env vars, this shouldn't happen if you're only reading process.env values.

Still Stuck?

Ask your coding agent for help. Paste the error message and it can diagnose the issue.

Prompt: Debug Server Component useState Error
I'm getting a React hooks error in my Next.js 16 app.
 
**Error:**
```
___PASTE_EXACT_ERROR_MESSAGE___
```
 
Example errors:
- "useState is not a function"
- "React hooks can only be called inside a function component"
- "Invalid hook call"
 
**My component:**
```tsx
// File: _____
// Example: src/app/dashboard/counter.tsx
 
___PASTE_YOUR_FULL_COMPONENT___
```
 
**Questions:**
1. Is this a Server Component or Client Component?
2. Am I missing a `'use client'` directive?
3. Am I importing from the wrong place?
 
Help me understand whether this is a Server/Client boundary issue and how to fix it.
Prompt: Debug Hydration Mismatch
I'm getting a hydration mismatch error in Next.js.
 
**Error:**
```
___PASTE_HYDRATION_ERROR___
```
 
Example: "Hydration failed because the server rendered content doesn't match the client"
 
**My component:**
```tsx
// File: _____
 
___PASTE_YOUR_COMPONENT___
```
 
**What I think might differ between server and client:**
- [ ] I'm using `Date.now()` or `Math.random()`
- [ ] I'm checking `window` or `document`
- [ ] I'm using browser-only APIs
- [ ] I'm not sure
 
**The mismatch I see:**
- Server renders: _____
- Client renders: _____
 
Help me identify what's causing the server/client mismatch and how to fix it.

Solution

Complete file implementations

Server Component (unchanged from starter)

apps/web/src/components/server-env-display.tsx
export function ServerEnvDisplay() {
  return (
    <div className="rounded border p-4">
      <h3 className="font-bold">Server Component</h3>
      <p>Public: {process.env.NEXT_PUBLIC_APP_NAME}</p>
      <p>Server-only: {process.env.INTERNAL_CONFIG}</p>
    </div>
  )
}

Client Component with Interactivity

apps/web/src/components/client-env-display.tsx
'use client'
 
import { useState } from 'react'
 
export function ClientEnvDisplay() {
  const [clicks, setClicks] = useState(0)
  
  return (
    <div className="rounded border p-4">
      <h3 className="font-bold">Client Component</h3>
      <p>Public: {process.env.NEXT_PUBLIC_APP_NAME}</p>
      <p>Server-only: {process.env.INTERNAL_CONFIG || 'undefined'}</p>
      <button
        type="button"
        onClick={() => setClicks(clicks + 1)}
        className="mt-2 rounded bg-blue-500 px-3 py-1 text-white hover:bg-blue-600"
      >
        Clicked {clicks} times
      </button>
    </div>
  )
}

Collapsible Wrapper (Composition Pattern)

apps/web/src/components/collapsible.tsx
'use client'
 
import { useState, type ReactNode } from 'react'
 
export function Collapsible({ 
  title, 
  children 
}: { 
  title: string
  children: ReactNode 
}) {
  const [isOpen, setIsOpen] = useState(true)
  
  return (
    <div className="rounded border">
      <button
        type="button"
        onClick={() => setIsOpen(!isOpen)}
        className="flex w-full items-center justify-between p-4 text-left font-semibold hover:bg-gray-50"
      >
        {title}
        <span>{isOpen ? '−' : '+'}</span>
      </button>
      {isOpen && <div className="border-t p-4">{children}</div>}
    </div>
  )
}

Page Composing Both

apps/web/src/app/env-demo/page.tsx
import { ServerEnvDisplay } from '@/components/server-env-display'
import { Collapsible } from '@/components/collapsible'
import { ClientEnvDisplay } from '@/components/client-env-display'
 
export default function EnvDemoPage() {
  return (
    <main className="flex flex-col gap-4 p-4">
      <h1 className="text-2xl font-bold">Environment Variable Demo</h1>
      <Collapsible title="Server-Rendered Content">
        <ServerEnvDisplay />
      </Collapsible>
      <ClientEnvDisplay />
    </main>
  )
}

Learn More

What's Next

You now understand the Server/Client boundary and how to compose components across it. In the next lesson, you'll learn how to handle dynamic routes with params, like /blog/[slug], and how to access those params in both Server and Client Components.