Skip to main content

Next.js Component Patterns

When building applications with Next.js, organizing your components effectively can drastically improve your development experience and application performance. In this guide, we'll explore various component patterns that will help you write cleaner, more maintainable, and more efficient Next.js applications.

Introduction

Next.js is a powerful React framework that enables developers to build full-stack web applications with ease. At the heart of any React-based application are components - the building blocks that make up your user interface. Understanding different component patterns in Next.js will help you organize your code better and solve common problems more efficiently.

In this guide, we'll cover several essential component patterns for Next.js applications, including:

  1. Presentational vs Container Components
  2. Layout Components
  3. Higher Order Components (HOCs)
  4. Composition Patterns
  5. Server and Client Components (Next.js 13+)

Presentational vs Container Components

The Pattern Explained

One of the most common patterns in React and Next.js development is separating components into two categories:

  • Presentational Components: Focus solely on how things look (UI)
  • Container Components: Focus on how things work (data fetching, state management)

This separation of concerns makes your code more maintainable and reusable.

Example Implementation

Here's how you might implement this pattern in a Next.js application:

jsx
// components/UserProfile/UserProfileView.jsx (Presentational Component)
export default function UserProfileView({ name, email, bio, avatar }) {
return (
<div className="user-profile">
<img src={avatar} alt={name} className="avatar" />
<h2>{name}</h2>
<p className="email">{email}</p>
<div className="bio">{bio}</div>
</div>
);
}
jsx
// components/UserProfile/UserProfileContainer.jsx (Container Component)
import { useState, useEffect } from 'react';
import UserProfileView from './UserProfileView';

export default function UserProfileContainer({ userId }) {
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
async function fetchUserData() {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);

if (!response.ok) {
throw new Error('Failed to fetch user data');
}

const data = await response.json();
setUserData(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}

fetchUserData();
}, [userId]);

if (loading) return <p>Loading user profile...</p>;
if (error) return <p>Error: {error}</p>;
if (!userData) return null;

return <UserProfileView {...userData} />;
}
jsx
// pages/profile/[id].js
import UserProfileContainer from '../../components/UserProfile/UserProfileContainer';
import { useRouter } from 'next/router';

export default function ProfilePage() {
const router = useRouter();
const { id } = router.query;

return (
<div className="profile-page">
<h1>User Profile</h1>
{id && <UserProfileContainer userId={id} />}
</div>
);
}

Benefits

  • Separation of concerns: UI logic is separate from business logic
  • Reusability: Presentational components can be reused across different containers
  • Testability: Each component type can be tested in isolation
  • Maintainability: Easier to understand and modify components with specific purposes

Layout Components

The Pattern Explained

Layout components provide a consistent structure across multiple pages of your application. They typically include elements that remain consistent across pages, such as headers, footers, and navigation menus.

Example Implementation

jsx
// components/layouts/MainLayout.jsx
import Head from 'next/head';
import Navbar from '../Navbar';
import Footer from '../Footer';

export default function MainLayout({ children, title = 'Next.js App' }) {
return (
<>
<Head>
<title>{title}</title>
<meta charSet="utf-8" />
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
</Head>
<div className="layout">
<Navbar />
<main className="content">
{children}
</main>
<Footer />
</div>
</>
);
}
jsx
// pages/about.js
import MainLayout from '../components/layouts/MainLayout';

export default function AboutPage() {
return (
<MainLayout title="About Us | Next.js App">
<h1>About Our Company</h1>
<p>We are a team of passionate developers...</p>
</MainLayout>
);
}

Advanced: Layout Pattern in Next.js 13+ App Directory

In Next.js 13+ with the App Router, layouts work slightly differently:

jsx
// app/layout.js
import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
import '../styles/globals.css';

export const metadata = {
title: 'Next.js Application',
description: 'A modern Next.js application',
};

export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Navbar />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
jsx
// app/about/page.js
export const metadata = {
title: 'About Us | Next.js Application',
};

export default function AboutPage() {
return (
<div className="about-page">
<h1>About Our Company</h1>
<p>We are a team of passionate developers...</p>
</div>
);
}

Higher Order Components (HOCs)

The Pattern Explained

Higher Order Components are functions that take a component and return a new enhanced component with additional props or behavior. They're useful for cross-cutting concerns like authentication, data fetching, or logging.

Example Implementation

Here's a simple HOC that adds authentication checks:

jsx
// hoc/withAuth.js
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';

export default function withAuth(Component) {
return function AuthenticatedComponent(props) {
const router = useRouter();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);

useEffect(() => {
// Check if user is authenticated
async function checkAuth() {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();

if (data.authenticated) {
setIsAuthenticated(true);
} else {
router.push('/login');
}
} catch (error) {
console.error('Auth check failed:', error);
router.push('/login');
} finally {
setLoading(false);
}
}

checkAuth();
}, [router]);

if (loading) {
return <div>Checking authentication...</div>;
}

if (!isAuthenticated) {
return null; // Router will redirect
}

return <Component {...props} />;
};
}

Using the HOC:

jsx
// pages/dashboard.js
import withAuth from '../hoc/withAuth';

function DashboardPage() {
return (
<div className="dashboard">
<h1>Dashboard</h1>
<p>Welcome to your protected dashboard!</p>
</div>
);
}

export default withAuth(DashboardPage);

Composition Patterns

The Pattern Explained

Component composition is about building complex UIs by combining simpler components. In React and Next.js, we can use composition patterns to create flexible and maintainable component hierarchies.

Example Implementation: Compound Components

Compound components are a pattern where components are designed to work together to provide a cohesive experience while maintaining separation of concerns.

jsx
// components/Tabs/index.js
import { createContext, useState, useContext } from 'react';

// Create context for tabs
const TabsContext = createContext(null);

function Tabs({ children, defaultTab }) {
const [activeTab, setActiveTab] = useState(defaultTab || 0);

return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs-container">
{children}
</div>
</TabsContext.Provider>
);
}

function TabList({ children }) {
return (
<div className="tab-list">
{children}
</div>
);
}

function Tab({ children, index }) {
const { activeTab, setActiveTab } = useContext(TabsContext);

return (
<button
className={`tab ${activeTab === index ? 'active' : ''}`}
onClick={() => setActiveTab(index)}
>
{children}
</button>
);
}

function TabPanels({ children }) {
return <div className="tab-panels">{children}</div>;
}

function TabPanel({ children, index }) {
const { activeTab } = useContext(TabsContext);

if (activeTab !== index) return null;

return <div className="tab-panel">{children}</div>;
}

// Attach components to the Tabs component
Tabs.TabList = TabList;
Tabs.Tab = Tab;
Tabs.TabPanels = TabPanels;
Tabs.TabPanel = TabPanel;

export default Tabs;

Using compound components:

jsx
// pages/products.js
import Tabs from '../components/Tabs';

export default function ProductsPage() {
return (
<div className="products-page">
<h1>Our Products</h1>

<Tabs defaultTab={0}>
<Tabs.TabList>
<Tabs.Tab index={0}>Electronics</Tabs.Tab>
<Tabs.Tab index={1}>Clothing</Tabs.Tab>
<Tabs.Tab index={2}>Books</Tabs.Tab>
</Tabs.TabList>

<Tabs.TabPanels>
<Tabs.TabPanel index={0}>
<h2>Electronics Products</h2>
<p>Browse our latest electronics...</p>
</Tabs.TabPanel>

<Tabs.TabPanel index={1}>
<h2>Clothing Collection</h2>
<p>Check out our fashion items...</p>
</Tabs.TabPanel>

<Tabs.TabPanel index={2}>
<h2>Book Catalog</h2>
<p>Explore our vast collection of books...</p>
</Tabs.TabPanel>
</Tabs.TabPanels>
</Tabs>
</div>
);
}

Server and Client Components (Next.js 13+)

The Pattern Explained

Next.js 13+ introduced a revolutionary pattern with React Server Components, allowing components to render on the server by default. This enables better performance and simplified data fetching patterns.

Key Differences

Server ComponentsClient Components
Render on serverRender on client
No useState, useEffectCan use hooks and browser APIs
Direct access to backend resourcesNo direct backend access
No event handlersCan include interactivity
Reduced client-side JavaScriptIncreases bundle size

Example Implementation

jsx
// app/products/page.js
// This is a Server Component by default
import ProductList from './ProductList';
import { getProducts } from '../../lib/products';

export default async function ProductsPage() {
// Direct database or API call (server-side only)
const products = await getProducts();

return (
<div>
<h1>Our Products</h1>
<ProductList initialProducts={products} />
</div>
);
}
jsx
// app/products/ProductList.js
"use client"; // This marks it as a Client Component

import { useState } from 'react';

export default function ProductList({ initialProducts }) {
const [filteredProducts, setFilteredProducts] = useState(initialProducts);
const [filter, setFilter] = useState('');

// Client-side filtering
const handleFilterChange = (e) => {
const value = e.target.value.toLowerCase();
setFilter(value);

setFilteredProducts(
initialProducts.filter(product =>
product.name.toLowerCase().includes(value)
)
);
};

return (
<div>
<input
type="text"
placeholder="Filter products..."
value={filter}
onChange={handleFilterChange}
className="filter-input"
/>

<ul className="product-list">
{filteredProducts.map(product => (
<li key={product.id} className="product-item">
<h3>{product.name}</h3>
<p>${product.price}</p>
</li>
))}
</ul>
</div>
);
}

When to Use Which

  • Server Components (Default in app/ directory):

    • Data fetching
    • Access to backend resources
    • Reduced client-side JavaScript
    • SEO-critical content
  • Client Components (Add "use client" directive):

    • Interactive UI elements
    • Event handlers
    • useState, useEffect hooks
    • Browser APIs

Practical Example: Building a Blog with Multiple Patterns

Let's combine these patterns in a practical example - a simple blog page:

jsx
// app/blog/page.js (Server Component)
import { getPosts } from '../../lib/posts';
import BlogLayout from '../../components/layouts/BlogLayout';
import PostList from './PostList';

export const metadata = {
title: 'Blog | Next.js Patterns',
description: 'Our latest articles and updates'
};

export default async function BlogPage() {
// Server-side data fetching
const posts = await getPosts();

return (
<BlogLayout>
<h1>Our Blog</h1>
<PostList initialPosts={posts} />
</BlogLayout>
);
}
jsx
// components/layouts/BlogLayout.jsx
import Sidebar from '../Sidebar';

export default function BlogLayout({ children }) {
return (
<div className="blog-layout">
<div className="content">
{children}
</div>
<Sidebar />
</div>
);
}
jsx
// app/blog/PostList.js (Client Component)
"use client";

import { useState } from 'react';
import PostCard from './PostCard';

export default function PostList({ initialPosts }) {
const [posts, setPosts] = useState(initialPosts);
const [category, setCategory] = useState('all');

const filterByCategory = (selectedCategory) => {
setCategory(selectedCategory);

if (selectedCategory === 'all') {
setPosts(initialPosts);
} else {
setPosts(initialPosts.filter(post =>
post.category === selectedCategory
));
}
};

return (
<div className="post-container">
<div className="category-filter">
<button
onClick={() => filterByCategory('all')}
className={category === 'all' ? 'active' : ''}
>
All
</button>
<button
onClick={() => filterByCategory('nextjs')}
className={category === 'nextjs' ? 'active' : ''}
>
Next.js
</button>
<button
onClick={() => filterByCategory('react')}
className={category === 'react' ? 'active' : ''}
>
React
</button>
</div>

<div className="post-grid">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
);
}
jsx
// app/blog/PostCard.js (Client Component)
"use client";

import { useState } from 'react';
import Link from 'next/link';

export default function PostCard({ post }) {
const [likes, setLikes] = useState(post.likes);

const handleLike = async () => {
setLikes(likes + 1);

// Update likes on server
await fetch(`/api/posts/${post.id}/like`, {
method: 'POST'
});
};

return (
<div className="post-card">
<img src={post.image} alt={post.title} />
<div className="post-content">
<h2>{post.title}</h2>
<p className="post-excerpt">{post.excerpt}</p>
<div className="post-meta">
<span>{new Date(post.date).toLocaleDateString()}</span>
<span>{post.readTime} min read</span>
</div>
<div className="post-actions">
<Link href={`/blog/${post.slug}`}>
Read more
</Link>
<button onClick={handleLike} className="like-button">
❤️ {likes}
</button>
</div>
</div>
</div>
);
}

Summary

In this guide, we've explored essential component patterns in Next.js:

  1. Presentational vs Container Components: Separating UI from logic
  2. Layout Components: Creating consistent page structures
  3. Higher Order Components: Enhancing components with additional functionality
  4. Composition Patterns: Building complex UIs from simpler parts
  5. Server and Client Components: Leveraging Next.js 13+ features for optimal performance

Using these patterns effectively will help you build Next.js applications that are:

  • More maintainable
  • More scalable
  • More performant
  • Easier to test
  • More developer-friendly

Remember that the best pattern depends on your specific use case. Sometimes combining multiple patterns yields the best results.

Additional Resources

Exercises

  1. Practice Exercise: Convert an existing page in your Next.js application to use the container/presentational pattern.

  2. Challenge: Create a reusable authentication HOC that redirects unauthenticated users and provides user information to authenticated components.

  3. Project Exercise: Build a dashboard layout using the layout component pattern that includes a sidebar, header, and main content area.

  4. Advanced Challenge: Create a set of compound components for a custom dropdown or select menu system.

  5. Next.js 13+ Exercise: Convert an application to use the App Router and experiment with both Server and Client Components.

By mastering these patterns, you'll be well-equipped to build sophisticated Next.js applications that are both powerful and maintainable.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)