Skip to content

Next.js Component Development

Components are the fundamental building blocks of Next.js applications. This chapter will detail how to create, organize, and optimize React components in Next.js.

Component Basics

Function Components

typescript
// components/Button.tsx
interface ButtonProps {
  children: React.ReactNode
  onClick?: () => void
  variant?: 'primary' | 'secondary'
  disabled?: boolean
}

export default function Button({ 
  children, 
  onClick, 
  variant = 'primary',
  disabled = false 
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant} ${disabled ? 'btn-disabled' : ''}`}
    >
      {children}
    </button>
  )
}

Component Usage

typescript
// app/page.tsx
import Button from '@/components/Button'

export default function HomePage() {
  const handleClick = () => {
    console.log('Button clicked')
  }

  return (
    <div>
      <h1>Welcome to Next.js</h1>
      <Button onClick={handleClick}>
        Click me
      </Button>
      <Button variant="secondary" disabled>
        Disabled button
      </Button>
    </div>
  )
}

Server Components vs Client Components

Server Components (Default)

typescript
// components/PostList.tsx
interface Post {
  id: string
  title: string
  excerpt: string
  publishedAt: string
}

async function fetchPosts(): Promise<Post[]> {
  const res = await fetch('https://api.example.com/posts')
  return res.json()
}

export default async function PostList() {
  const posts = await fetchPosts()

  return (
    <div className="post-list">
      {posts.map((post) => (
        <article key={post.id} className="post-card">
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
          <time>{new Date(post.publishedAt).toLocaleDateString()}</time>
        </article>
      ))}
    </div>
  )
}

Client Components

typescript
// components/SearchBox.tsx
'use client'

import { useState, useEffect } from 'react'

interface SearchBoxProps {
  onSearch: (query: string) => void
  placeholder?: string
}

export default function SearchBox({ onSearch, placeholder = 'Search...' }: SearchBoxProps) {
  const [query, setQuery] = useState('')
  const [debounced, setDebounced] = useState('')

  // Debounce handling
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebounced(query)
    }, 300)

    return () => clearTimeout(timer)
  }, [query])

  useEffect(() => {
    if (debounced) {
      onSearch(debounced)
    }
  }, [debounced, onSearch])

  return (
    <div className="search-box">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder={placeholder}
        className="search-input"
      />
      {query && (
        <button
          onClick={() => setQuery('')}
          className="clear-button"
        >

        </button>
      )}
    </div>
  )
}

Component Composition Patterns

Compound Component Pattern

typescript
// components/Card/index.tsx
interface CardProps {
  children: React.ReactNode
  className?: string
}

function Card({ children, className = '' }: CardProps) {
  return (
    <div className={`card ${className}`}>
      {children}
    </div>
  )
}

function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="card-header">{children}</div>
}

function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="card-body">{children}</div>
}

function CardFooter({ children }: { children: React.ReactNode }) {
  return <div className="card-footer">{children}</div>
}

// Export compound component
Card.Header = CardHeader
Card.Body = CardBody
Card.Footer = CardFooter

export default Card

Using Compound Components

typescript
// app/profile/page.tsx
import Card from '@/components/Card'

export default function ProfilePage() {
  return (
    <div>
      <Card>
        <Card.Header>
          <h2>User Profile</h2>
        </Card.Header>
        <Card.Body>
          <p>Name: John Doe</p>
          <p>Email: john@example.com</p>
        </Card.Body>
        <Card.Footer>
          <button>Edit Profile</button>
        </Card.Footer>
      </Card>
    </div>
  )
}

Higher-Order Components (HOC)

Creating HOC

typescript
// hoc/withAuth.tsx
'use client'

import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'

interface User {
  id: string
  name: string
  email: string
}

function withAuth<P extends object>(
  WrappedComponent: React.ComponentType<P & { user: User }>
) {
  return function AuthenticatedComponent(props: P) {
    const [user, setUser] = useState<User | null>(null)
    const [loading, setLoading] = useState(true)
    const router = useRouter()

    useEffect(() => {
      const checkAuth = async () => {
        try {
          const response = await fetch('/api/auth/me')
          if (response.ok) {
            const userData = await response.json()
            setUser(userData)
          } else {
            router.push('/login')
          }
        } catch (error) {
          router.push('/login')
        } finally {
          setLoading(false)
        }
      }

      checkAuth()
    }, [router])

    if (loading) {
      return <div>Verifying identity...</div>
    }

    if (!user) {
      return null
    }

    return <WrappedComponent {...props} user={user} />
  }
}

export default withAuth

Using HOC

typescript
// components/Dashboard.tsx
'use client'

import withAuth from '@/hoc/withAuth'

interface DashboardProps {
  user: {
    id: string
    name: string
    email: string
  }
}

function Dashboard({ user }: DashboardProps) {
  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  )
}

export default withAuth(Dashboard)

Custom Hooks

Data Fetching Hook

typescript
// hooks/useFetch.ts
'use client'

import { useState, useEffect } from 'react'

interface FetchState<T> {
  data: T | null
  loading: boolean
  error: string | null
}

function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    loading: true,
    error: null
  })

  useEffect(() => {
    const fetchData = async () => {
      try {
        setState(prev => ({ ...prev, loading: true, error: null }))
        
        const response = await fetch(url)
        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 : 'Unknown error'
        })
      }
    }

    fetchData()
  }, [url])

  return state
}

export default useFetch

Local Storage Hook

typescript
// hooks/useLocalStorage.ts
'use client'

import { useState, useEffect } from 'react'

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(initialValue)

  useEffect(() => {
    try {
      const item = window.localStorage.getItem(key)
      if (item) {
        setStoredValue(JSON.parse(item))
      }
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error)
    }
  }, [key])

  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value
      setStoredValue(valueToStore)
      window.localStorage.setItem(key, JSON.stringify(valueToStore))
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error)
    }
  }

  return [storedValue, setValue] as const
}

export default useLocalStorage

Using Custom Hooks

typescript
// components/UserProfile.tsx
'use client'

import useFetch from '@/hooks/useFetch'
import useLocalStorage from '@/hooks/useLocalStorage'

interface User {
  id: string
  name: string
  email: string
}

export default function UserProfile({ userId }: { userId: string }) {
  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`)
  const [theme, setTheme] = useLocalStorage('theme', 'light')

  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>
  if (!user) return <div>User not found</div>

  return (
    <div className={`profile theme-${theme}`}>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        Toggle Theme
      </button>
    </div>
  )
}

Form Components

Controlled Form Component

typescript
// components/ContactForm.tsx
'use client'

import { useState } from 'react'

interface FormData {
  name: string
  email: string
  message: string
}

interface ContactFormProps {
  onSubmit: (data: FormData) => Promise<void>
}

export default function ContactForm({ onSubmit }: ContactFormProps) {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    message: ''
  })
  const [errors, setErrors] = useState<Partial<FormData>>({})
  const [submitting, setSubmitting] = useState(false)

  const validateForm = (): boolean => {
    const newErrors: Partial<FormData> = {}

    if (!formData.name.trim()) {
      newErrors.name = 'Name is required'
    }

    if (!formData.email.trim()) {
      newErrors.email = 'Email is required'
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email = 'Invalid email format'
    }

    if (!formData.message.trim()) {
      newErrors.message = 'Message is required'
    }

    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    
    if (!validateForm()) return

    setSubmitting(true)
    try {
      await onSubmit(formData)
      setFormData({ name: '', email: '', message: '' })
      setErrors({})
    } catch (error) {
      console.error('Submit failed:', error)
    } finally {
      setSubmitting(false)
    }
  }

  const handleChange = (field: keyof FormData) => (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    setFormData(prev => ({
      ...prev,
      [field]: e.target.value
    }))
    
    // Clear corresponding field error
    if (errors[field]) {
      setErrors(prev => ({
        ...prev,
        [field]: undefined
      }))
    }
  }

  return (
    <form onSubmit={handleSubmit} className="contact-form">
      <div className="form-group">
        <label htmlFor="name">Name</label>
        <input
          id="name"
          type="text"
          value={formData.name}
          onChange={handleChange('name')}
          className={errors.name ? 'error' : ''}
        />
        {errors.name && <span className="error-message">{errors.name}</span>}
      </div>

      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={formData.email}
          onChange={handleChange('email')}
          className={errors.email ? 'error' : ''}
        />
        {errors.email && <span className="error-message">{errors.email}</span>}
      </div>

      <div className="form-group">
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          value={formData.message}
          onChange={handleChange('message')}
          className={errors.message ? 'error' : ''}
          rows={4}
        />
        {errors.message && <span className="error-message">{errors.message}</span>}
      </div>

      <button type="submit" disabled={submitting}>
        {submitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  )
}

List and Data Display Components

Data Table Component

typescript
// components/DataTable.tsx
'use client'

import { useState } from 'react'

interface Column<T> {
  key: keyof T
  title: string
  render?: (value: any, record: T) => React.ReactNode
  sortable?: boolean
}

interface DataTableProps<T> {
  data: T[]
  columns: Column<T>[]
  loading?: boolean
  pagination?: {
    current: number
    pageSize: number
    total: number
    onChange: (page: number) => void
  }
}

export default function DataTable<T extends { id: string | number }>({
  data,
  columns,
  loading = false,
  pagination
}: DataTableProps<T>) {
  const [sortField, setSortField] = useState<keyof T | null>(null)
  const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')

  const handleSort = (field: keyof T) => {
    if (sortField === field) {
      setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
    } else {
      setSortField(field)
      setSortDirection('asc')
    }
  }

  const sortedData = [...data].sort((a, b) => {
    if (!sortField) return 0
    
    const aValue = a[sortField]
    const bValue = b[sortField]
    
    if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1
    if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1
    return 0
  })

  if (loading) {
    return <div className="table-loading">Loading...</div>
  }

  return (
    <div className="data-table">
      <table>
        <thead>
          <tr>
            {columns.map((column) => (
              <th
                key={String(column.key)}
                onClick={() => column.sortable && handleSort(column.key)}
                className={column.sortable ? 'sortable' : ''}
              >
                {column.title}
                {column.sortable && sortField === column.key && (
                  <span className="sort-indicator">
                    {sortDirection === 'asc' ? ' ↑' : ' ↓'}
                  </span>
                )}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {sortedData.map((record) => (
            <tr key={record.id}>
              {columns.map((column) => (
                <td key={String(column.key)}>
                  {column.render
                    ? column.render(record[column.key], record)
                    : String(record[column.key])
                  }
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      {pagination && (
        <div className="pagination">
          <button
            onClick={() => pagination.onChange(pagination.current - 1)}
            disabled={pagination.current <= 1}
          >
            Previous
          </button>
          <span>
            Page {pagination.current} of {Math.ceil(pagination.total / pagination.pageSize)}
          </span>
          <button
            onClick={() => pagination.onChange(pagination.current + 1)}
            disabled={pagination.current >= Math.ceil(pagination.total / pagination.pageSize)}
          >
            Next
          </button>
        </div>
      )}
    </div>
  )
}

Using Data Table

typescript
// app/users/page.tsx
'use client'

import { useState, useEffect } from 'react'
import DataTable from '@/components/DataTable'

interface User {
  id: string
  name: string
  email: string
  role: string
  createdAt: string
}

export default function UsersPage() {
  const [users, setUsers] = useState<User[]>([])
  const [loading, setLoading] = useState(true)
  const [pagination, setPagination] = useState({
    current: 1,
    pageSize: 10,
    total: 0
  })

  const columns = [
    {
      key: 'name' as keyof User,
      title: 'Name',
      sortable: true
    },
    {
      key: 'email' as keyof User,
      title: 'Email',
      sortable: true
    },
    {
      key: 'role' as keyof User,
      title: 'Role',
      render: (role: string) => (
        <span className={`role-badge role-${role}`}>
          {role}
        </span>
      )
    },
    {
      key: 'createdAt' as keyof User,
      title: 'Created At',
      render: (date: string) => new Date(date).toLocaleDateString(),
      sortable: true
    }
  ]

  useEffect(() => {
    fetchUsers()
  }, [pagination.current])

  const fetchUsers = async () => {
    setLoading(true)
    try {
      const response = await fetch(
        `/api/users?page=${pagination.current}&pageSize=${pagination.pageSize}`
      )
      const data = await response.json()
      setUsers(data.users)
      setPagination(prev => ({ ...prev, total: data.total }))
    } catch (error) {
      console.error('Failed to fetch users:', error)
    } finally {
      setLoading(false)
    }
  }

  const handlePageChange = (page: number) => {
    setPagination(prev => ({ ...prev, current: page }))
  }

  return (
    <div>
      <h1>User Management</h1>
      <DataTable
        data={users}
        columns={columns}
        loading={loading}
        pagination={{
          ...pagination,
          onChange: handlePageChange
        }}
      />
    </div>
  )
}

Component Optimization

React.memo Optimization

typescript
// components/ExpensiveComponent.tsx
import React from 'react'

interface ExpensiveComponentProps {
  data: any[]
  onItemClick: (id: string) => void
}

const ExpensiveComponent = React.memo(function ExpensiveComponent({
  data,
  onItemClick
}: ExpensiveComponentProps) {
  console.log('ExpensiveComponent re-rendered')
  
  return (
    <div>
      {data.map((item) => (
        <div key={item.id} onClick={() => onItemClick(item.id)}>
          {item.name}
        </div>
      ))}
    </div>
  )
}, (prevProps, nextProps) => {
  // Custom comparison function
  return (
    prevProps.data.length === nextProps.data.length &&
    prevProps.onItemClick === nextProps.onItemClick
  )
})

export default ExpensiveComponent

useCallback and useMemo

typescript
// components/OptimizedParent.tsx
'use client'

import { useState, useCallback, useMemo } from 'react'
import ExpensiveComponent from './ExpensiveComponent'

export default function OptimizedParent() {
  const [items, setItems] = useState([])
  const [filter, setFilter] = useState('')

  // Use useMemo to cache computed results
  const filteredItems = useMemo(() => {
    return items.filter(item => 
      item.name.toLowerCase().includes(filter.toLowerCase())
    )
  }, [items, filter])

  // Use useCallback to cache functions
  const handleItemClick = useCallback((id: string) => {
    console.log('Clicked item:', id)
  }, [])

  return (
    <div>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="Search..."
      />
      <ExpensiveComponent
        data={filteredItems}
        onItemClick={handleItemClick}
      />
    </div>
  )
}

Component Testing

Unit Testing

typescript
// __tests__/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import Button from '@/components/Button'

describe('Button Component', () => {
  it('should render button text', () => {
    render(<Button>Click me</Button>)
    expect(screen.getByText('Click me')).toBeInTheDocument()
  })

  it('should handle click events', () => {
    const handleClick = jest.fn()
    render(<Button onClick={handleClick}>Click me</Button>)
    
    fireEvent.click(screen.getByText('Click me'))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('should not trigger click when disabled', () => {
    const handleClick = jest.fn()
    render(
      <Button onClick={handleClick} disabled>
        Disabled button
      </Button>
    )
    
    fireEvent.click(screen.getByText('Disabled button'))
    expect(handleClick).not.toHaveBeenCalled()
  })
})

Component Documentation

Using Storybook

typescript
// stories/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import Button from '@/components/Button'

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: ['primary', 'secondary'],
    },
  },
}

export default meta
type Story = StoryObj<typeof meta>

export const Primary: Story = {
  args: {
    children: 'Primary Button',
    variant: 'primary',
  },
}

export const Secondary: Story = {
  args: {
    children: 'Secondary Button',
    variant: 'secondary',
  },
}

export const Disabled: Story = {
  args: {
    children: 'Disabled Button',
    disabled: true,
  },
}

Summary

Key points for Next.js component development:

  • Choose component types wisely - Server Components for data fetching, Client Components for interactivity
  • Component reusability - Improve reusability through props and composition patterns
  • Performance optimization - Use memo, useCallback, useMemo for optimization
  • Type safety - Use TypeScript for clear interface definitions
  • Test coverage - Write unit tests to ensure component quality
  • Complete documentation - Use tools like Storybook to maintain component documentation

Good component design is the foundation for building maintainable and scalable Next.js applications.

Content is for learning and research only.