Skip to main content

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>&copy; 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>
);
}
// 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