Next.js Design Patterns
When building applications with Next.js, understanding common design patterns can help you write cleaner, more maintainable, and scalable code. This guide explores essential design patterns specific to Next.js applications.
Introduction to Design Patterns in Next.js
Design patterns are proven solutions to common problems in software development. In the context of Next.js, these patterns help manage state, organize components, handle data fetching, and structure your application efficiently.
As a React framework, Next.js inherits many React design patterns while introducing its own patterns specific to server-side rendering, static site generation, and its file-based routing system.
1. Layout Pattern
One of the most fundamental patterns in Next.js is the Layout pattern, which allows you to share UI elements across multiple pages.
Implementation
In Next.js 13+ with the App Router:
// app/layout.js
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header>
<nav>{/* Navigation items */}</nav>
</header>
<main>{children}</main>
<footer>{/* Footer content */}</footer>
</body>
</html>
);
}
For nested layouts:
// app/dashboard/layout.js
export default function DashboardLayout({ children }) {
return (
<div className="dashboard-container">
<aside className="sidebar">{/* Sidebar content */}</aside>
<div className="content">{children}</div>
</div>
);
}
Benefits
- Reduces code duplication
- Ensures consistent UI across pages
- Improves maintainability
- Allows for nested layouts with the App Router
2. Data Fetching Pattern
Next.js provides several ways to fetch data, each suited for different scenarios.
Server Components Data Fetching
// app/products/page.js
async function getProducts() {
const res = await fetch('https://api.example.com/products');
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<div>
<h1>Products</h1>
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
);
}
Client-Side Data Fetching with SWR
For data that needs to be frequently updated or requires client-side interaction:
// components/UserProfile.js
'use client';
import useSWR from 'swr';
const fetcher = (...args) => fetch(...args).then(res => res.json());
export default function UserProfile({ userId }) {
const { data, error, isLoading } = useSWR(
`/api/user/${userId}`,
fetcher
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading user</div>;
return (
<div>
<h2>{data.name}</h2>
<p>{data.email}</p>
</div>
);
}
Benefits
- Server Components fetch data on the server, reducing client-side JavaScript
- SWR provides caching, revalidation, and error handling
- Separate data fetching logic from rendering logic
3. Component Composition Pattern
Breaking down UI into reusable components is a core React pattern that's equally important in Next.js.
Example
// components/ProductCard.js
export default function ProductCard({ product }) {
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
<button>Add to Cart</button>
</div>
);
}
// app/products/page.js
import ProductCard from '@/components/ProductCard';
async function getProducts() {
// Fetch products
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<div className="products-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Benefits
- Improves code readability and maintainability
- Promotes reusability
- Makes testing easier
- Follows the single responsibility principle
4. Provider Pattern
For global state management and providing context to components.
Implementation
// providers/ThemeProvider.js
'use client';
import { createContext, useState, useContext } from 'react';
const ThemeContext = createContext(undefined);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
Using the provider:
// app/layout.js
import { ThemeProvider } from '@/providers/ThemeProvider';
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
Using the context in a component:
// components/ThemeToggle.js
'use client';
import { useTheme } from '@/providers/ThemeProvider';
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
);
}
Benefits
- Centralizes state management
- Avoids prop drilling
- Makes global functionality accessible throughout your application
- Separates state logic from components
5. Route Handlers Pattern
Next.js allows creating API endpoints using route handlers, perfect for backend functionality.
Implementation
// app/api/newsletter/route.js
import { NextResponse } from 'next/server';
export async function POST(request) {
try {
const { email } = await request.json();
// Validate email
if (!email || !email.includes('@')) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Save email to database (pseudo-code)
// await db.subscriptions.create({ email });
return NextResponse.json(
{ message: 'Subscription successful' },
{ status: 200 }
);
} catch (error) {
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
}
}
Using the API endpoint:
// components/NewsletterForm.js
'use client';
import { useState } from 'react';
export default function NewsletterForm() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState(null);
async function handleSubmit(e) {
e.preventDefault();
setStatus('loading');
try {
const response = await fetch('/api/newsletter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Something went wrong');
}
setStatus('success');
setEmail('');
} catch (error) {
setStatus('error');
console.error(error);
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Your email"
required
/>
<button type="submit" disabled={status === 'loading'}>
{status === 'loading' ? 'Subscribing...' : 'Subscribe'}
</button>
{status === 'success' && <p>Thank you for subscribing!</p>}
{status === 'error' && <p>Something went wrong. Please try again.</p>}
</form>
);
}
Benefits
- Separates backend and frontend concerns
- Provides secure server-side execution
- Creates a clear API structure
- Simplifies backend functionality in a Next.js app
6. Parallel Data Fetching Pattern
For optimizing data loading performance by fetching multiple resources in parallel.
Implementation
// app/dashboard/page.js
async function getUser(userId) {
const res = await fetch(`https://api.example.com/users/${userId}`);
return res.json();
}
async function getOrders(userId) {
const res = await fetch(`https://api.example.com/orders?userId=${userId}`);
return res.json();
}
async function getNotifications(userId) {
const res = await fetch(`https://api.example.com/notifications?userId=${userId}`);
return res.json();
}
export default async function DashboardPage() {
const userId = '123'; // Could come from auth context or params
// Fetch data in parallel
const [user, orders, notifications] = await Promise.all([
getUser(userId),
getOrders(userId),
getNotifications(userId)
]);
return (
<div>
<h1>Welcome, {user.name}</h1>
<section>
<h2>Recent Orders ({orders.length})</h2>
{/* Display orders */}
</section>
<section>
<h2>Notifications ({notifications.length})</h2>
{/* Display notifications */}
</section>
</div>
);
}
Benefits
- Improves loading performance
- Reduces overall page load time
- Better user experience
- Efficiently utilizes server and network resources
7. Error Boundary Pattern
For gracefully handling errors in your application.
Implementation
// components/ErrorBoundary.js
'use client';
import { useState } from 'react';
export default function ErrorBoundary({ children, fallback }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
return fallback || <div>Something went wrong</div>;
}
return (
<div onError={() => setHasError(true)}>
{children}
</div>
);
}
Using the error boundary:
// app/products/[id]/page.js
import ErrorBoundary from '@/components/ErrorBoundary';
import ProductDetails from '@/components/ProductDetails';
export default function ProductPage({ params }) {
return (
<ErrorBoundary fallback={<div>Failed to load product</div>}>
<ProductDetails productId={params.id} />
</ErrorBoundary>
);
}
Next.js 13+ also provides built-in error handling with error.js
files:
// app/products/[id]/error.js
'use client';
import { useEffect } from 'react';
export default function Error({ error, reset }) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
Benefits
- Prevents crashes from breaking the entire application
- Provides a better user experience
- Allows for graceful fallbacks
- Helps with debugging and error reporting
Real-World Example: Building an E-commerce Product Page
Let's combine several patterns to build a realistic product detail page:
// app/products/[slug]/page.js
import { notFound } from 'next/navigation';
import ProductDetails from '@/components/ProductDetails';
import RelatedProducts from '@/components/RelatedProducts';
import AddToCartButton from '@/components/AddToCartButton';
import ProductReviews from '@/components/ProductReviews';
import { CartProvider } from '@/providers/CartProvider';
// Data fetchers
async function getProduct(slug) {
const res = await fetch(`https://api.mystore.com/products/${slug}`);
if (!res.ok) return null;
return res.json();
}
async function getRelatedProducts(category) {
const res = await fetch(`https://api.mystore.com/products?category=${category}&limit=4`);
return res.json();
}
export default async function ProductPage({ params }) {
const product = await getProduct(params.slug);
// Handle 404 cases
if (!product) {
notFound();
}
// Fetch related products in parallel
const relatedProducts = await getRelatedProducts(product.category);
return (
<div className="product-page">
<CartProvider>
<section className="product-main">
<ProductDetails product={product} />
<AddToCartButton product={product} />
</section>
<section className="product-reviews">
<ProductReviews productId={product.id} />
</section>
<section className="related-products">
<h2>You might also like</h2>
<RelatedProducts products={relatedProducts} />
</section>
</CartProvider>
</div>
);
}
This example demonstrates:
- Layout pattern for structuring the product page
- Data fetching pattern for getting product details
- Parallel data fetching for related products
- Component composition for breaking down the UI
- Provider pattern for cart functionality
- Error handling with
notFound()
Summary
Next.js design patterns help you build maintainable, scalable applications by providing structured approaches to common problems. The patterns we've covered include:
- Layout Pattern - For consistent UI across multiple pages
- Data Fetching Pattern - For efficient server and client-side data retrieval
- Component Composition Pattern - For breaking down UI into reusable parts
- Provider Pattern - For global state and context management
- Route Handlers Pattern - For creating API endpoints
- Parallel Data Fetching Pattern - For optimizing loading performance
- Error Boundary Pattern - For gracefully handling errors
By understanding and applying these patterns, you'll be able to write cleaner code, improve performance, and create better user experiences in your Next.js applications.
Additional Resources
- Next.js Documentation
- React Design Patterns
- SWR Documentation
- Kent C. Dodds' Advanced React Patterns
Exercises
- Convert an existing page in your application to use the Layout pattern.
- Implement parallel data fetching in a dashboard page that needs to display user information, orders, and account settings.
- Create a theme provider that allows users to switch between light and dark modes.
- Build a route handler for a newsletter subscription form with proper validation and error handling.
- Refactor a complex page into smaller, reusable components following the component composition pattern.
By practicing these design patterns, you'll develop stronger Next.js applications that are easier to maintain and scale.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)