Advanced Next.js Patterns
Learn advanced patterns for building scalable Next.js applications.
Part of the Next.js Mastery Series
Advanced Patterns
In this second part of our series, we dive into advanced patterns.
Compound Components
Compound components are a powerful pattern for building flexible APIs.
<Tabs>
<Tabs.List>
<Tabs.Trigger value="one">One</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="one">Content One</Tabs.Content>
</Tabs>
Stay tuned for more!
šÆ Quick Answer
Advanced Next.js Patterns such as Compound Components, Render Props, and the Context + Composition pattern allow you to build truly reusable, scalable component libraries that are easy to maintain and test. These patterns are the backbone of enterprise-grade Next.js applications.
Why Advanced Patterns Matter
When Next.js applications grow, naive component structures quickly become unmaintainable. Props drilling, over-reliance on global state, and tangled component hierarchies are all symptoms of skipping architectural patterns. Advanced patterns solve these real-world problems with proven, composable solutions.
Compound Components ā Deep Dive
The Compound Component pattern lets a parent component share implicit state with its children via React Context, giving consumers a declarative, expressive API without prop drilling.
import { createContext, useContext, useState } from 'react';
const TabsContext = createContext<{ active: string; setActive: (v: string) => void } | null>(null);
function Tabs({ defaultValue, children }: { defaultValue: string; children: React.ReactNode }) {
const [active, setActive] = useState(defaultValue);
return <TabsContext.Provider value={{ active, setActive }}>{children}</TabsContext.Provider>;
}
Tabs.List = function TabsList({ children }: { children: React.ReactNode }) {
return <div role="tablist" className="flex gap-2">{children}</div>;
};
Tabs.Trigger = function TabsTrigger({ value, children }: { value: string; children: React.ReactNode }) {
const ctx = useContext(TabsContext)!;
return (
<button
role="tab"
aria-selected={ctx.active === value}
onClick={() => ctx.setActive(value)}
className={ctx.active === value ? 'font-bold border-b-2 border-orange-600' : 'text-zinc-500'}
>
{children}
</button>
);
};
Tabs.Content = function TabsContent({ value, children }: { value: string; children: React.ReactNode }) {
const ctx = useContext(TabsContext)!;
return ctx.active === value ? <div role="tabpanel">{children}</div> : null;
};
Usage:
<Tabs defaultValue="one">
<Tabs.List>
<Tabs.Trigger value="one">Overview</Tabs.Trigger>
<Tabs.Trigger value="two">Details</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="one">Overview content here.</Tabs.Content>
<Tabs.Content value="two">Detailed content here.</Tabs.Content>
</Tabs>
The Render Props Pattern
Render props are useful when you need to share stateful logic without dictating the UI. While React Hooks have largely replaced this pattern for new code, it remains valuable when working with class-based components or third-party libraries.
function MouseTracker({ render }: { render: (pos: { x: number; y: number }) => React.ReactNode }) {
const [pos, setPos] = useState({ x: 0, y: 0 });
return (
<div onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })} style={{ height: '100vh' }}>
{render(pos)}
</div>
);
}
// Usage
<MouseTracker render={({ x, y }) => <p>Mouse at {x}, {y}</p>} />
Higher-Order Components (HOC)
HOCs wrap a component to inject props or behavior. They are still common in authentication guards and analytics wrappers.
function withAuth<T extends object>(Component: React.ComponentType<T>) {
return function AuthGuard(props: T) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) return <Redirect to="/login" />;
return <Component {...props} />;
};
}
const ProtectedDashboard = withAuth(Dashboard);
Server Component Composition in Next.js 15
In Next.js 15, Server Components allow you to compose data-fetching logic at the component level without waterfalls:
// app/dashboard/page.tsx ā Server Component
import { Suspense } from 'react';
import { UserStats } from './UserStats';
import { RecentActivity } from './RecentActivity';
export default function Dashboard() {
return (
<div className="grid grid-cols-2 gap-8">
<Suspense fallback={<StatsSkeleton />}>
<UserStats /> {/* fetches its own data */}
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity /> {/* fetches its own data, in parallel */}
</Suspense>
</div>
);
}
Each child fetches data in parallel, and each has its own Suspense boundary ā delivering content to the browser as it becomes ready.
URL State Pattern
Using URL search params as state is a powerful Next.js-specific pattern. It makes state shareable, bookmarkable, and SSR-compatible:
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
export function FilterBar() {
const router = useRouter();
const params = useSearchParams();
const setFilter = (key: string, value: string) => {
const next = new URLSearchParams(params.toString());
next.set(key, value);
router.push(`?${next.toString()}`);
};
return (
<select onChange={e => setFilter('category', e.target.value)} defaultValue={params.get('category') ?? ''}>
<option value="">All</option>
<option value="testing">Testing</option>
<option value="devops">DevOps</option>
</select>
);
}
š Step-by-Step Implementation
Choose the right pattern
Match the pattern to the problem: Compound Components for UI APIs, Render Props for shared logic, HOCs for cross-cutting concerns, Server Component Composition for data fetching.
Define the public API first
Write the consumer code (how you want to USE the component) before implementing it. This keeps APIs intuitive.
Use TypeScript strictly
Type your compound component sub-components, context values, and HOC generics. TypeScript catches misuse at compile time.
Write tests alongside
Each pattern should have unit tests for state transitions and integration tests for user interactions.
ā Best Practices
- āPrefer Hooks over Render Props for new code ā they are more readable and composable.
- āUse the
displayNameproperty on HOCs to preserve component names in React DevTools. - āKeep Compound Component context private ā don't export the context, only the parent and its sub-components.
- āCollocate data fetching with the component that needs it using Server Components ā avoid prop drilling data down from page level.
Frequently Asked Questions
When should I use Compound Components vs simple props?
Use Compound Components when a component has multiple related sub-parts that consumers need to arrange or configure independently. Simple props work fine for small, self-contained components.
Are HOCs still relevant in 2026?
Less so for new code, but they remain relevant in large codebases that haven't migrated to Hooks, and for wrapping third-party class components.
How do I test Compound Components?
Test them as a unit using React Testing Library. Render the parent with all sub-components and assert on the resulting DOM ā don't test sub-components in isolation.
Conclusion
Mastering these advanced Next.js patterns gives your applications a solid architectural foundation. Compound Components keep your component APIs clean and flexible, HOCs handle cross-cutting concerns, and Server Component Composition enables efficient, parallel data fetching. Apply these patterns intentionally ā the goal is maintainability and clarity, not pattern for pattern's sake.
š Summary & Key Takeaways
Advanced Next.js patterns ā Compound Components, Render Props, HOCs, and Server Component Composition ā solve real architectural problems in growing applications. Compound Components share state implicitly via Context, HOCs inject cross-cutting concerns, and Server Components parallelize data fetching at the component level. The URL State pattern leverages Next.js routing to make UI state shareable and SSR-compatible. Choose patterns based on the problem, always define the consumer API first, and back every pattern with tests.
Optimistic UI Updates
Optimistic updates improve perceived performance by immediately reflecting the expected result of a user action in the UI, then reconciling with the server response when it arrives:
'use client';
import { useOptimistic, useTransition } from 'react';
type Todo = { id: number; text: string; completed: boolean };
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
);
const [, startTransition] = useTransition();
const addTodo = async (text: string) => {
const tempTodo = { id: Date.now(), text, completed: false };
startTransition(() => {
addOptimistic(tempTodo);
});
await fetch('/api/todos', { method: 'POST', body: JSON.stringify({ text }) });
};
return (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} className={todo.id > 1e12 ? 'opacity-50' : ''}>
{todo.text}
</li>
))}
</ul>
);
}
Items added optimistically appear immediately at full opacity but are rendered at 50% opacity while the network request is in flight.
Parallel Route Interception
Next.js 15 supports intercepting routes to show content in a modal while keeping the underlying page visible ā ideal for photo galleries, login forms, and quick-view panels:
app/
āāā @modal/
ā āāā (.)photo/[id]/
ā āāā page.tsx ā Intercepts /photo/[id] when navigated client-side
āāā photo/
ā āāā [id]/
ā āāā page.tsx ā Full page when navigated directly or refreshed
āāā layout.tsx
This pattern delivers a modal experience on client-side navigation and a full-page experience on direct URL access or refresh ā perfectly supporting social media sharing and deep linking.
Testing Advanced Patterns
Every pattern discussed in this series should be covered by tests. Here is a concise testing strategy:
// Testing Compound Components
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Tabs from './Tabs';
describe('Tabs compound component', () => {
it('shows only the active tab content', async () => {
render(
<Tabs defaultValue="tab1">
<Tabs.List>
<Tabs.Trigger value="tab1">First</Tabs.Trigger>
<Tabs.Trigger value="tab2">Second</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="tab1">Content 1</Tabs.Content>
<Tabs.Content value="tab2">Content 2</Tabs.Content>
</Tabs>
);
expect(screen.getByText('Content 1')).toBeVisible();
expect(screen.queryByText('Content 2')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('tab', { name: 'Second' }));
expect(screen.getByText('Content 2')).toBeVisible();
});
});
Testing compound components as a unit ā rather than testing each sub-component in isolation ā gives you higher confidence that the API works correctly from a consumer's perspective.
Share it with your network and help others learn too!
Follow me on social media for more developer tips, tricks, and tutorials. Let's connect and build something great together!