Next.js App Router + shadcn/ui: A Guide to Mixing Server and Client Components
It’s 3 AM. I’m staring at the error on my screen: Error: You're importing a component that needs useEffect. It only works in a Client Component but none of its parents are marked with "use client".
I already added "use client" to layout.tsx, why is it still failing?
After digging through documentation for hours, I realized the problem was in component import boundaries. The line between Server Components and Client Components in App Router is far more complex than I thought.
This is a real scenario many developers face when migrating to App Router. The framework defaults all components to Server Components, but UI libraries like shadcn/ui mostly need Client Components. Where should the boundary be drawn? How should data flow? How do we optimize performance?
This article aims to clarify these questions completely.
Server Components vs Client Components: The Fundamental Difference
Let’s start with the basics: in App Router, all components are Server Components by default.
What does this mean? Your page.tsx and layout.tsx files render on the server by default, sending zero JavaScript to the browser.
What Server Components Can Do
The core advantage of Server Components is being “closer to data”:
// app/products/page.tsx - Server Component (default)
async function ProductsPage() {
// Directly await data in component
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // Cache for 1 hour
}).then(res => res.json())
return (
<div>
{products.map(p => (
<div key={p.id}>{p.name} - ${p.price}</div>
))}
</div>
)
}
See? No useEffect, no useState, just await to fetch data. This is the “async component” feature of Server Components.
Best for:
- Data fetching (fetch, database queries)
- Accessing backend APIs (headers(), cookies())
- Large dependency libraries (like markdown parsers 100KB+, won’t be bundled to browser)
- Handling sensitive information (API keys never exposed to frontend)
What Client Components Can Do
Client Components are the “traditional React components” we’re familiar with. Just add "use client" at the top:
// components/like-button.tsx
'use client'
import { useState } from 'react'
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
const [count, setCount] = useState(0)
const handleClick = () => {
setLiked(!liked)
setCount(prev => liked ? prev - 1 : prev + 1)
}
return (
<button onClick={handleClick}>
{liked ? '❤️' : '🤍'} {count}
</button>
)
}
Best for:
- Event handling (onClick, onChange, onSubmit)
- React hooks (useState, useEffect, useRef, useContext)
- Browser APIs (localStorage, window, document)
- Context Providers
Here’s a counter-intuitive point: Client Components also get pre-rendered to HTML on the server. They just hydrate in the browser afterward to restore interactivity. So users still see complete content on first visit, not “white screen waiting for JS to load”.
Core Rules: Who Can Import Who
This part is where most mistakes happen.
The rules are simple, but many people get them backwards:
- Server Component can import Client Component ✅
- Client Component cannot import Server Component ❌
- Server Component can be passed as children to Client Component ✅
The third rule is a bit tricky, the code makes it clear:
// app/page.tsx - Server Component
import { ClientContainer } from './client-container'
import { ServerData } from './server-data'
export default function Page() {
return (
<ClientContainer>
{/* ServerData passed as children */}
<ServerData />
</ClientContainer>
)
}
// client-container.tsx
'use client'
export function ClientContainer({ children }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children}
</div>
)
}
// server-data.tsx - Server Component
async function ServerData() {
const data = await fetch('/api/data').then(r => r.json())
return <div>{data.title}</div>
}
This pattern is common: Client Container handles interaction logic, Server Data handles data fetching. They’re isolated via children, not direct imports.
shadcn/ui Integration: Why It’s “Troublesome”
shadcn/ui is my favorite UI library, but using it in App Router requires some tricks.
The root cause: shadcn/ui is based on Radix UI, most components use React hooks.
Components like Button, Dialog, and Dropdown Menu all have useState or useEffect internally. So they must be Client Components.
Wrong Example: Using shadcn/ui Directly in Server Component
// ❌ Wrong: Server Component importing Client Component
import { Button } from '@/components/ui/button'
async function ProductPage() {
const product = await fetchProduct()
return (
<div>
<h1>{product.name}</h1>
{/* This will error: Button needs "use client" */}
<Button onClick={() => addToCart(product.id)}>
Add to Cart
</Button>
</div>
)
}
Error message: Button uses useState, must be marked "use client".
Correct Solution 1: Extract Interactive Parts as Client Component
The most common and simplest approach:
// app/product/page.tsx - Server Component
import { ProductInfo } from './product-info'
import { AddToCartButton } from './add-to-cart-button'
async function ProductPage({ params }) {
const product = await fetchProduct(params.id)
return (
<div>
{/* Server Component: handles data display */}
<ProductInfo product={product} />
{/* Client Component: handles interaction */}
<AddToCartButton productId={product.id} />
</div>
)
}
// product-info.tsx - Server Component
export function ProductInfo({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
)
}
// add-to-cart-button.tsx - Client Component
'use client'
import { Button } from '@/components/ui/button'
import { useState } from 'react'
export function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false)
const handleAdd = async () => {
setLoading(true)
await addToCart(productId)
setLoading(false)
}
return (
<Button onClick={handleAdd} disabled={loading}>
{loading ? 'Adding...' : 'Add to Cart'}
</Button>
)
}
Core idea: Extract interactive parts as separate leaf nodes, keep other parts as Server Components.
Correct Solution 2: Composition Pattern (Server Passes Data to Client)
If Client Component needs initial data:
// app/dashboard/page.tsx - Server Component
import { DataTable } from './data-table'
async function DashboardPage() {
const users = await fetchUsers() // Server Component fetches data
return <DataTable data={users} /> // Pass to Client Component
}
// data-table.tsx - Client Component
'use client'
import { Table } from '@/components/ui/table'
import { useState } from 'react'
export function DataTable({ data }) {
const [selectedRows, setSelectedRows] = useState([])
return (
<Table>
{/* shadcn/ui Table component */}
<TableBody>
{data.map(user => (
<TableRow
key={user.id}
selected={selectedRows.includes(user.id)}
onClick={() => toggleSelection(user.id)}
>
<TableCell>{user.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
This gives you Server Component’s data fetching advantage plus Client Component’s interactivity.
Where to Place Context Providers
Another common confusion: where should global Context Providers (like ThemeProvider, AuthProvider) go?
Answer: Must be in Client Component, but “as deep as possible”.
// app/layout.tsx - Server Component (root layout)
export default function RootLayout({ children }) {
return (
<html>
<body>
{/* Don't put Provider here */}
{children}
</body>
</html>
)
}
// app/providers.tsx - Client Component
'use client'
import { ThemeProvider } from 'next-themes'
import { AuthProvider } from './auth-context'
export function Providers({ children }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
)
}
// app/dashboard/layout.tsx - Server Component
import { Providers } from '../providers'
export default function DashboardLayout({ children }) {
return (
<Providers>
{children}
</Providers>
)
}
Why “deep”? Because Provider turns all wrapped components into Client Component subtree. If placed in root layout, the entire app gets forced into client-side rendering.
Placing at deeper level (like a specific route’s layout) minimizes Provider’s impact scope.
Data Flow: Passing from Server to Client
Props are the simplest and most reliable approach:
// Server Component fetches data
const data = await fetchData()
// Pass to Client Component
<ClientComponent initialData={data} />
But there’s a performance optimization: React.cache() function.
If multiple Server Components need the same data, use cache to prevent duplicate requests:
// lib/get-user.ts
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
return await db.query('SELECT * FROM users WHERE id = ?', [id])
})
// app/layout.tsx
async function Layout() {
const user = await getUser('123') // First request
return <header>{user.name}</header>
}
// app/page.tsx
async function Page() {
const user = await getUser('123') // Same params, won't duplicate request
return <main>Welcome {user.name}</main>
}
cache automatically deduplicates calls with same params during a single render cycle.
Four Most Common Mistakes
Mistake 1: Abusing “use client” at High Level
// ❌ app/layout.tsx with "use client"
'use client'
export default function Layout({ children }) {
return <div>{children}</div>
}
This turns the entire app’s subtree into Client Components, losing Server Component performance benefits.
Fix: Only add "use client" to components that truly need interaction, keep it at leaf nodes.
Mistake 2: Using Hooks in Server Component
// ❌ Server Component using useState
async function Page() {
const [count, setCount] = useState(0) // Error!
return <div>{count}</div>
}
Fix: Extract parts that need hooks into Client Component.
Mistake 3: Using headers()/cookies() in Client Component
// ❌ Client Component using server APIs
'use client'
import { headers } from 'next/headers'
function UserProfile() {
const headersList = headers() // Error! Only works in Server Component
return <div>...</div>
}
Fix: Fetch data in Server Component, then pass to Client Component:
// Server Component fetches headers
async function Page() {
const userAgent = headers().get('user-agent')
return <UserProfile userAgent={userAgent} />
}
// Client Component receives data
'use client'
function UserProfile({ userAgent }) {
return <div>Browser: {userAgent}</div>
}
Mistake 4: Third-party Components Not Marked “use client”
// ❌ Server Component importing unmarked third-party component
import { AcmeCarousel } from 'acme-carousel'
async function Page() {
return <AcmeCarousel /> // Error! AcmeCarousel uses hooks internally
}
Fix: Create a wrapper:
// components/carousel-wrapper.tsx
'use client'
import { AcmeCarousel } from 'acme-carousel'
export function CarouselWrapper(props) {
return <AcmeCarousel {...props} />
}
// page.tsx - Server Component
import { CarouselWrapper } from './carousel-wrapper'
async function Page() {
return <CarouselWrapper /> // Works correctly
}
Performance Optimization Tips
A few practical techniques:
1. Keep Client Components at Leaf Nodes
This rule can reduce client-side JavaScript by 70%.
For a product listing page:
- Product grid: Server Component
- Each product card: Server Component
- Quantity selector on card: Client Component (only interactive part)
2. Use Suspense for Streaming Rendering
// app/page.tsx
import { Suspense } from 'react'
import { ProductList } from './product-list'
import { Recommendations } from './recommendations'
export default function Page() {
return (
<div>
{/* Show skeleton first, replace when data arrives */}
<Suspense fallback={<ProductSkeleton />}>
<ProductList />
</Suspense>
{/* Secondary content streams independently */}
<Suspense fallback={<RecSkeleton />}>
<Recommendations />
</Suspense>
</div>
)
}
Users see the page frame first, then data fills in progressively. Much better than “waiting for all data to load”.
3. Fetch Caching Strategies
// Static data (fetched at build time)
await fetch(url, { cache: 'force-cache' })
// ISR: Revalidate every hour
await fetch(url, { next: { revalidate: 3600 } })
// Dynamic data (fetch on every request)
await fetch(url, { cache: 'no-store' })
Choose caching strategy wisely to avoid excessive dynamic rendering.
Summary
After all this, the core points are just a few:
- Default to Server Components, only use Client Components when interaction is needed
- Server can import Client, but Client cannot import Server
- Pass data via children or props, keep boundaries clear
- Add “use client” at leaf nodes, don’t abuse it at high levels
- Extract shadcn/ui components separately, don’t mix them in Server Components
The Server/Client boundary in App Router was designed to let developers “stay closer to data, further from browser”. Understanding this makes many puzzles resolve naturally.
I suggest starting practice with simple pages: write Server Component to fetch data first, then gradually add interactive parts. Don’t panic when errors occur—it’s usually a boundary issue. Check component import relationships and you’ll locate the problem quickly.
Properly Mixing Server and Client Components
Best practices for integrating shadcn/ui in Next.js App Router projects
⏱️ Estimated time: 30 min
- 1
Step1: Identify Component Type Requirements
Determine if each component needs interaction:
• Needs event handling (onClick, onChange) → Client Component
• Needs React hooks (useState, useEffect) → Client Component
• Needs browser APIs (localStorage, window) → Client Component
• Only displays data, no interaction → Server Component (default) - 2
Step2: Extract Interactive Parts as Leaf Nodes
Separate interactive parts into Client Components:
• Create new file, add 'use client' at top
• Import shadcn/ui components (Button, Dialog, etc.)
• Import this Client Component in Server Component
• Pass data via props - 3
Step3: Design Data Flow
Server Component fetches data, passes to Client Component:
• Server Component uses async/await to fetch data
• Pass to Client Component via props
• When multiple places need same data, use React.cache()
• Avoid using headers()/cookies() directly in Client Components - 4
Step4: Place Context Providers
Providers must be Client Components, but place in deep layout:
• Create providers.tsx, mark 'use client'
• Wrap ThemeProvider, AuthProvider, etc.
• Import in specific route's layout.tsx (not root layout)
• Minimize Client Component subtree scope - 5
Step5: Verify and Optimize
Check if component boundaries are correct:
• Ensure 'use client' only at leaf nodes
• Check no Client Component imports Server Component
• Use Suspense to wrap async components
• Configure reasonable fetch caching strategy
FAQ
Why must shadcn/ui components use Client Components?
Can Server and Client Components import each other?
How to avoid duplicate data requests in multiple Server Components?
Where should Context Providers be placed?
What to do when encountering 'useEffect only works in Client Component' error?
How to decide if a component should be Server or Client Component?
Series: This article is part of the Next.js Complete Guide series (Article 46). If you’re learning Next.js App Router, check out other articles in the series. For more shadcn/ui practical techniques, see the Tailwind & shadcn/ui Practical Guide series.
6 min read · Published on: Mar 31, 2026 · Modified on: Mar 31, 2026
Related Posts
Astro + Tailwind: Configuring Island Components and Global Styles Without Conflicts
Astro + Tailwind: Configuring Island Components and Global Styles Without Conflicts
React Compiler + shadcn/ui: Frontend Development in the Auto-Optimization Era
React Compiler + shadcn/ui: Frontend Development in the Auto-Optimization Era
shadcn/ui and Radix: How to Maintain Accessibility When Customizing Components

Comments
Sign in with GitHub to leave a comment