Components
React components in Next.js, Server Components, Client Components, and component patterns for modern applications.
Server Components vs Client Components
Server Components (Default in App Router)
// app/components/ServerComponent.tsx
// Runs on the server, can access backend resources directly
import { db } from '@/lib/database';
export default async function ServerComponent() {
// This runs on the server
const posts = await db.posts.findMany();
return (
<div>
<h1>Latest Posts</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
// Benefits:
// - Direct database access
// - Reduced bundle size
// - Better SEO
// - Faster initial page load
// - Server-side data fetching
Client Components
// app/components/ClientComponent.tsx
'use client';
import { useState, useEffect } from 'react';
export default function ClientComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
// Client-side effects
console.log('Component mounted');
}, []);
const handleClick = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
}
// Use client components for:
// - Event handlers
// - useState, useEffect
// - Browser APIs
// - Interactive features
Mixed Component Pattern
// app/components/MixedComponent.tsx (Server Component)
import ClientInteractive from './ClientInteractive';
export default async function MixedComponent() {
// Server-side data fetching
const data = await fetch('https://api.example.com/data');
const posts = await data.json();
return (
<div>
<h1>Server-rendered Content</h1>
{/* Static server content */}
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
{/* Client-side interactive component */}
<ClientInteractive initialData={posts} />
</div>
);
}
// app/components/ClientInteractive.tsx
'use client';
import { useState } from 'react';
interface Props {
initialData: any[];
}
export default function ClientInteractive({ initialData }: Props) {
const [filtered, setFiltered] = useState(initialData);
const handleFilter = (term: string) => {
const filtered = initialData.filter(item =>
item.title.toLowerCase().includes(term.toLowerCase())
);
setFiltered(filtered);
};
return (
<div>
<input
type="text"
onChange={(e) => handleFilter(e.target.value)}
placeholder="Filter posts..."
/>
<div>
{filtered.map(item => (
<div key={item.id}>{item.title}</div>
))}
</div>
</div>
);
}
Component Patterns
Layout Components
// app/components/Layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<nav className="max-w-7xl mx-auto px-4">
<div className="flex justify-between h-16">
<div className="flex items-center">
<h1 className="text-xl font-bold">My App</h1>
</div>
<div className="flex items-center space-x-4">
<a href="/about">About</a>
<a href="/contact">Contact</a>
</div>
</div>
</nav>
</header>
<main className="max-w-7xl mx-auto py-6 px-4">
{children}
</main>
<footer className="bg-gray-800 text-white py-8">
<div className="max-w-7xl mx-auto px-4 text-center">
<p>© 2024 My App. All rights reserved.</p>
</div>
</footer>
</div>
);
}
Card Components
// app/components/Card.tsx
interface CardProps {
title: string;
content: string;
image?: string;
href?: string;
className?: string;
}
export default function Card({
title,
content,
image,
href,
className = ''
}: CardProps) {
const CardContent = (
<div className={`bg-white rounded-lg shadow-md overflow-hidden ${className}`}>
{image && (
<img
src={image}
alt={title}
className="w-full h-48 object-cover"
/>
)}
<div className="p-6">
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-gray-600">{content}</p>
</div>
</div>
);
if (href) {
return (
<a href={href} className="block hover:transform hover:scale-105 transition-transform">
{CardContent}
</a>
);
}
return CardContent;
}
// Usage
export default function HomePage() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card
title="Getting Started"
content="Learn the basics of Next.js"
image="/images/getting-started.jpg"
href="/docs/getting-started"
/>
<Card
title="Advanced Features"
content="Explore advanced Next.js features"
image="/images/advanced.jpg"
href="/docs/advanced"
/>
</div>
);
}
Form Components
// app/components/ContactForm.tsx
'use client';
import { useState } from 'react';
export default function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (response.ok) {
setSubmitted(true);
setFormData({ name: '', email: '', message: '' });
}
} catch (error) {
console.error('Error submitting form:', error);
} finally {
setIsSubmitting(false);
}
};
if (submitted) {
return (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
Thank you for your message! We'll get back to you soon.
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
Message
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
required
rows={4}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 disabled:opacity-50"
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
Modal Components
// app/components/Modal.tsx
'use client';
import { useEffect } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="fixed inset-0 bg-black bg-opacity-50"
onClick={onClose}
/>
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold">{title}</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6">
{children}
</div>
</div>
</div>
);
}
// Usage
'use client';
import { useState } from 'react';
import Modal from './Modal';
export default function ModalExample() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div>
<button
onClick={() => setIsModalOpen(true)}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
Open Modal
</button>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Example Modal"
>
<p>This is modal content!</p>
<button
onClick={() => setIsModalOpen(false)}
className="mt-4 bg-gray-600 text-white px-4 py-2 rounded"
>
Close
</button>
</Modal>
</div>
);
}
Component Composition
Higher-Order Components (HOCs)
// app/components/withAuth.tsx
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
export default function withAuth<T extends object>(
WrappedComponent: React.ComponentType<T>
) {
return function AuthComponent(props: T) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
useEffect(() => {
const checkAuth = async () => {
try {
const response = await fetch('/api/auth/check');
if (response.ok) {
setIsAuthenticated(true);
} else {
router.push('/login');
}
} catch (error) {
router.push('/login');
} finally {
setIsLoading(false);
}
};
checkAuth();
}, [router]);
if (isLoading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return null;
}
return <WrappedComponent {...props} />;
};
}
// Usage
const ProtectedDashboard = withAuth(Dashboard);
Render Props Pattern
// app/components/DataFetcher.tsx
'use client';
import { useState, useEffect } from 'react';
interface DataFetcherProps<T> {
url: string;
children: (data: T | null, loading: boolean, error: string | null) => React.ReactNode;
}
export default function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return <>{children(data, loading, error)}</>;
}
// Usage
export default function UserList() {
return (
<DataFetcher<User[]> url="/api/users">
{(users, loading, error) => {
if (loading) return <div>Loading users...</div>;
if (error) return <div>Error: {error}</div>;
if (!users) return <div>No users found</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}}
</DataFetcher>
);
}
Compound Components
// app/components/Tabs.tsx
'use client';
import { createContext, useContext, useState } from 'react';
interface TabsContextType {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextType | undefined>(undefined);
function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }: { children: React.ReactNode }) {
return (
<div className="flex border-b border-gray-200">
{children}
</div>
);
}
function Tab({ value, children }: { value: string; children: React.ReactNode }) {
const context = useContext(TabsContext);
if (!context) throw new Error('Tab must be used within Tabs');
const { activeTab, setActiveTab } = context;
const isActive = activeTab === value;
return (
<button
onClick={() => setActiveTab(value)}
className={`px-4 py-2 font-medium ${
isActive
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-600 hover:text-gray-800'
}`}
>
{children}
</button>
);
}
function TabPanel({ value, children }: { value: string; children: React.ReactNode }) {
const context = useContext(TabsContext);
if (!context) throw new Error('TabPanel must be used within Tabs');
const { activeTab } = context;
if (activeTab !== value) return null;
return <div className="py-4">{children}</div>;
}
// Compound component export
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
export default Tabs;
// Usage
export default function TabExample() {
return (
<Tabs defaultTab="tab1">
<Tabs.List>
<Tabs.Tab value="tab1">Tab 1</Tabs.Tab>
<Tabs.Tab value="tab2">Tab 2</Tabs.Tab>
<Tabs.Tab value="tab3">Tab 3</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="tab1">
<p>Content for tab 1</p>
</Tabs.Panel>
<Tabs.Panel value="tab2">
<p>Content for tab 2</p>
</Tabs.Panel>
<Tabs.Panel value="tab3">
<p>Content for tab 3</p>
</Tabs.Panel>
</Tabs>
);
}
Custom Hooks
useLocalStorage Hook
// app/hooks/useLocalStorage.ts
'use client';
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error('Error reading localStorage:', error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.error('Error setting localStorage:', error);
}
};
return [storedValue, setValue] as const;
}
// Usage
export default function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [language, setLanguage] = useLocalStorage('language', 'en');
return (
<div>
<label>
Theme:
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<label>
Language:
<select value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="en">English</option>
<option value="es">Spanish</option>
</select>
</label>
</div>
);
}
useApi Hook
// app/hooks/useApi.ts
'use client';
import { useState, useEffect } from 'react';
interface ApiState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
export function useApi<T>(url: string, options?: RequestInit) {
const [state, setState] = useState<ApiState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
const fetchData = async () => {
try {
setState(prev => ({ ...prev, loading: true, error: null }));
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setState({ data, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error.message : 'An error occurred',
});
}
};
fetchData();
}, [url]);
const refetch = () => {
setState(prev => ({ ...prev, loading: true, error: null }));
// Re-trigger the effect
};
return { ...state, refetch };
}
// Usage
export default function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useApi<User>(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Quick Reference
Server vs Client Components
- Server Components: Default in App Router, run on server, can't use hooks
- Client Components: Use 'use client' directive, run in browser, can use hooks
- Mixed Pattern: Server components can render client components
Component Types
- Layout Components: Shared UI structure
- Page Components: Route-specific content
- UI Components: Reusable interface elements
- Form Components: Interactive forms and inputs
Patterns
- HOCs: Wrap components with additional functionality
- Render Props: Pass rendering logic as props
- Compound Components: Related components that work together
- Custom Hooks: Reusable stateful logic
Best Practices
- Use Server Components by default
- Add 'use client' only when needed
- Keep components small and focused
- Use TypeScript for better type safety
- Implement proper error boundaries
- Use composition over inheritance
- Test components in isolation