Modern React and Next.js Patterns You Should Know in 2026
Explore the latest React and Next.js patterns and best practices that will make your code more maintainable and performant
The Morning I Onboarded A New Junior Developer
It was 9 AM on a Monday, first day of work, and I was onboarding a new junior developer onto our codebase. She'd learned React six months ago, built a few personal projects, and was eager to contribute.
"I've used React before," she said confidently. "Components, hooks, state management—I've got it."
I smiled and opened our main dashboard page. She stared at the screen for a moment, then tilted her head.
"Wait," she said, pointing at the code. "There's no useEffect. Where's the data fetching? And why are some components marked 'use client' while others aren't? What does 'use cache' mean? And... is that an API call inside a component?"
That's when it hit me. The React she learned six months ago was already outdated. The ecosystem had shifted beneath our feet, and most tutorials hadn't caught up.
This guide is for that junior developer—and for anyone else who wants to understand how we actually build React and Next.js applications in 2026.
The Big Picture: Where We Are in 2026
Let's start with the honest truth: React in 2026 isn't about learning new frameworks every six months. It's about the tools you already know finally working the way they should.
The stack that matters:
| Layer | 2026 Standard | What It Gives You |
|---|---|---|
| React | 19+ | Server Components, Actions, Compiler |
| Next.js | 16 | Turbopack, Cache Components, AI Debugging |
| Build Tools | Turbopack/Vite | 10x faster refresh, Rust-powered speed |
| Styling | Tailwind + CSS Native | Container queries, :has() selectors |
| Types | TypeScript 5.5+ | Strict by default, satisfaction guaranteed |
The theme isn't novelty—it's maturity. The experiments of 2023 and 2024 are now the defaults. And that changes everything about how we write code.
Pattern 1: Server Components Are No Longer Optional
This is the biggest mental shift you need to make. In 2026, every component is a Server Component by default. If you're still reaching for useEffect to fetch data, you're fighting the framework.
The Old Way (2024)
'use client';
import { useState, useEffect } from 'react';
export default function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
The 2026 Way
// app/users/[id]/page.tsx
import { db } from '@/lib/db'; // Direct database import!
import { Suspense } from 'react';
export default async function UserProfile({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
// Direct database query - no API layer needed
const user = await db.user.findUnique({
where: { id },
include: { posts: true }
});
if (!user) {
return <div>User not found</div>;
}
return (
<div>
<h1 className="text-2xl font-bold">{user.name}</h1>
<p className="text-gray-600">{user.email}</p>
<Suspense fallback={<div>Loading posts...</div>}>
<UserPosts userId={user.id} />
</Suspense>
</div>
);
}
// This is STILL a Server Component - no 'use client' needed!
async function UserPosts({ userId }: { userId: string }) {
const posts = await db.post.findMany({
where: { authorId: userId },
take: 10
});
return (
<div className="mt-8">
<h2 className="text-xl font-semibold">Recent Posts</h2>
{posts.map(post => (
<article key={post.id} className="mt-4 p-4 border rounded">
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
What changed?
- No more API routes for internal data: Server Components talk directly to your database
- Zero client JavaScript: These components add nothing to your bundle size
- Automatic security: Database credentials never leave the server
- Simplified data flow: No more waterfall of
useEffectcalls
The rule of thumb in 2026: Start with Server Components. Add 'use client' only when you need interactivity.
Pattern 2: The 'use client' Boundary Strategy
One of the hardest lessons I learned was where to put the 'use client' directive. Put it too high, and you're shipping too much JavaScript. Put it too low, and your interactive components break.
The Wrong Way
// app/dashboard/page.tsx
'use client'; // ❌ Everything below becomes client-rendered
import { useState } from 'react';
import { StatsGrid } from './StatsGrid';
import { Chart } from './Chart';
import { DataTable } from './DataTable';
export default function DashboardPage() {
const [dateRange, setDateRange] = useState('week');
return (
<div>
<StatsGrid dateRange={dateRange} />
<Chart dateRange={dateRange} />
<DataTable dateRange={dateRange} />
</div>
);
}
This forces every component—even static ones—to be client-rendered. Your bundle size balloons. Performance drops. Users suffer.
The Right Way
// app/dashboard/page.tsx (Server Component - no directive)
import { Suspense } from 'react';
import { StatsGrid } from './StatsGrid';
import { Chart } from './Chart';
import { DataTable } from './DataTable';
import { DateRangePicker } from './DateRangePicker'; // This is the ONLY client component
export default async function DashboardPage() {
// Data fetching happens on the server
const initialData = await fetchDashboardData();
return (
<div>
{/* Client component - interactive */}
<DateRangePicker initialRange="week" />
{/* These can still be server components! */}
<Suspense fallback={<div>Loading stats...</div>}>
<StatsGrid data={initialData.stats} />
</Suspense>
<Suspense fallback={<div>Loading chart...</div>}>
<Chart data={initialData.chartData} />
</Suspense>
<Suspense fallback={<div>Loading table...</div>}>
<DataTable data={initialData.rows} />
</Suspense>
</div>
);
}
// components/DateRangePicker.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function DateRangePicker({ initialRange }: { initialRange: string }) {
const [range, setRange] = useState(initialRange);
const router = useRouter();
const handleChange = (newRange: string) => {
setRange(newRange);
router.push(`/dashboard?range=${newRange}`);
};
return (
<select value={range} onChange={(e) => handleChange(e.target.value)}>
<option value="week">This Week</option>
<option value="month">This Month</option>
<option value="year">This Year</option>
</select>
);
}
The pattern: Push interactivity to the leaves. Your page is a Server Component. Only the parts that need state and event handlers become Client Components. Everything else stays on the server.
Pattern 3: Cache Components with 'use cache' (Next.js 16)
Next.js 16 introduced a pattern that changes how we think about caching: Cache Components. Powered by the 'use cache' directive, this gives you explicit control over what gets cached and for how long.
Before: Implicit Caching (Confusing)
// Was this cached? For how long? Who knows!
const data = await fetch('https://api.example.com/data');
After: Explicit Caching (Clear)
// next.config.ts
const nextConfig = {
cacheComponents: true, // Enable the feature
};
export default nextConfig;
// app/products/page.tsx
'use cache'; // Entire page is cached
export default async function ProductsPage() {
const products = await db.product.findMany();
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// For more granular control
export async function getProductData(id: string) {
'use cache';
// This function result is cached
const product = await db.product.findUnique({ where: { id } });
return product;
}
// With expiration profiles
export async function getAnalytics() {
'use cache';
// Use the cacheLife helper for standard durations
cacheLife('hours'); // or 'minutes', 'days', 'weeks'
const analytics = await fetchAnalytics();
return analytics;
}
What makes this powerful?
- Default dynamic: Code runs at request time unless explicitly cached
- Multiple levels: Cache at file, component, or function level
- Fine-grained control: Use
revalidateTag(tag, 'hours')for precise updates - Immediate updates:
updateTag(tag)in Server Actions refreshes cache instantly
// app/actions/products.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function updateProduct(formData: FormData) {
const id = formData.get('id');
const name = formData.get('name');
await db.product.update({ where: { id }, data: { name } });
// Immediately refresh the cache for this product
revalidateTag(`product-${id}`, 'hours');
}
This pattern makes caching intentional rather than accidental. You know exactly what's cached, and you control exactly when it updates.
Pattern 4: The Slot Pattern (asChild) Over Polymorphic Props
Remember the old "as" prop pattern? It caused so many TypeScript headaches:
// The old way (problematic)
<Button as="a" href="/dashboard">Dashboard</Button>
// TypeScript nightmare: Button needs to accept ALL possible props
In 2026, the industry has shifted to the Slot Pattern (often via asChild), popularized by libraries like Radix UI.
The Modern Way
// components/ui/Button.tsx
import { Slot } from '@radix-ui/react-slot';
import { forwardRef } from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
variant?: 'primary' | 'secondary' | 'outline';
size?: 'sm' | 'md' | 'lg';
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild = false, variant = 'primary', size = 'md', className, children, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
className={`btn btn-${variant} btn-${size} ${className}`}
{...props}
>
{children}
</Comp>
);
}
);
Button.displayName = 'Button';
Usage:
// As a button (default)
<Button onClick={handleClick}>Click me</Button>
// As a link (using Next.js Link)
<Button asChild>
<Link href="/dashboard">Dashboard</Link>
</Button>
// As a custom component
<Button asChild>
<a href="/dashboard" target="_blank">External Link</a>
</Button>
Why this wins:
- Perfect TypeScript: Button only cares about its own props. The child handles its own.
- Composition over configuration: You're not building a monolithic component that does everything
- Framework agnostic: Works with any HTML element or component
- Simpler code: No complex generic types, no prop merging logic
Pattern 5: Server Actions for Mutations
Server Actions have matured significantly. They're now the standard way to handle form submissions and data mutations.
The Complete Pattern
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/auth';
const createPostSchema = z.object({
title: z.string().min(5).max(100),
content: z.string().min(20),
published: z.boolean().default(false)
});
export async function createPost(formData: FormData) {
// 1. Validate input
const validated = createPostSchema.parse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on'
});
// 2. Check authorization
const user = await getCurrentUser();
if (!user) {
throw new Error('Unauthorized');
}
// 3. Perform mutation
const post = await db.post.create({
data: {
...validated,
authorId: user.id
}
});
// 4. Revalidate cache
revalidatePath('/posts');
revalidateTag('posts-list');
// 5. Return result (can be used by client)
return { success: true, postId: post.id };
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions/posts';
import { SubmitButton } from '@/components/SubmitButton';
export default function NewPostPage() {
return (
<form action={createPost} className="space-y-4">
<div>
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
name="title"
required
minLength={5}
className="w-full border rounded p-2"
/>
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
required
minLength={20}
rows={10}
className="w-full border rounded p-2"
/>
</div>
<div>
<label>
<input type="checkbox" name="published" />
Publish immediately
</label>
</div>
<SubmitButton>Create Post</SubmitButton>
</form>
);
}
// components/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
>
{pending ? 'Saving...' : children}
</button>
);
}
Key principles for Server Actions in 2026:
- Always validate with Zod or similar
- Always authorize—check permissions in every action
- Use
useFormStatusfor pending states - Revalidate strategically—only what changed
- Return meaningful data so clients can respond
Pattern 6: Feature-Sliced Design for Project Structure
The way we organize code has evolved. Dumping everything into a components folder doesn't scale. In 2026, the most maintainable large-scale Next.js applications use Feature-Sliced Design (FSD).
The Problem with Flat Structures
src/
├── components/
│ ├── Button.tsx
│ ├── UserCard.tsx
│ ├── ProductCard.tsx
│ ├── Header.tsx
│ └── ... 100 more files (chaos)
├── hooks/
├── utils/
└── pages/
After 10,000 lines, you can't find anything. Deleting a feature leaves orphaned components everywhere.
The Feature-Sliced Way
src/
├── app/ # Next.js App Router (routes only)
│ ├── layout.tsx
│ └── (routes)/
│ ├── dashboard/
│ └── products/
│
├── shared/ # Truly reusable, domain-agnostic
│ ├── ui/ # Design system: Button, Input, Card
│ ├── lib/ # Utilities: date formatting, constants
│ └── api/ # Generic API clients
│
├── entities/ # Business models
│ ├── user/
│ │ ├── ui/UserAvatar.tsx
│ │ ├── api/getUser.ts
│ │ └── types.ts
│ └── product/
│ ├── ui/ProductCard.tsx
│ ├── api/getProducts.ts
│ └── types.ts
│
├── features/ # User interactions
│ ├── auth/
│ │ ├── ui/LoginForm.tsx
│ │ ├── api/login.ts
│ │ └── lib/useSession.ts
│ └── create-post/
│ ├── ui/PostForm.tsx
│ ├── api/createPost.ts
│ └── lib/validation.ts
│
└── widgets/ # Composition units
├── header/
│ ├── ui/Header.tsx
│ └── lib/useHeaderData.ts
└── product-feed/
├── ui/ProductFeed.tsx
└── lib/useFeedData.ts
How Routing Integrates
// app/dashboard/page.tsx - ONLY routing
import { DashboardPage } from '@/widgets/dashboard-page';
export default function Page() {
return <DashboardPage />;
}
Why this works:
- Colocation: Everything related to a feature lives together
- Safe deletion: Delete a feature folder, and it's gone—no orphans
- Clear boundaries: Shared code is explicit, not accidental
- Scales to teams: Multiple teams can work without stepping on each other
Pattern 7: Headless UI with Composition
Another pattern that's become standard: separating logic from presentation using Headless UI components.
The Problem: Styled Components Aren't Reusable
// This dropdown is useless in another project
<Dropdown className="bg-white shadow-lg rounded">
<Dropdown.Trigger className="px-4 py-2 border">
Menu
</Dropdown.Trigger>
<Dropdown.Menu className="absolute mt-2 w-48">
<Dropdown.Item className="px-4 py-2 hover:bg-gray-100">
Profile
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
The Solution: Logic + Style Layers
// 1. Headless component (handles logic, no styles)
import { Menu } from '@headlessui/react';
// 2. Your styled wrapper
'use client';
import { Menu } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
interface DropdownProps {
trigger: React.ReactNode;
children: React.ReactNode;
}
export function Dropdown({ trigger, children }: DropdownProps) {
return (
<Menu as="div" className="relative inline-block text-left">
<Menu.Button className="inline-flex items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
{trigger}
<ChevronDownIcon className="-mr-1 h-5 w-5 text-gray-400" aria-hidden="true" />
</Menu.Button>
<Menu.Items className="absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{children}
</Menu.Items>
</Menu>
);
}
export function DropdownItem({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) {
return (
<Menu.Item>
{({ active }) => (
<button
onClick={onClick}
className={classNames(
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
'block w-full px-4 py-2 text-left text-sm'
)}
>
{children}
</button>
)}
</Menu.Item>
);
}
Usage:
<Dropdown trigger="Options">
<DropdownItem onClick={() => edit()}>Edit</DropdownItem>
<DropdownItem onClick={() => delete()}>Delete</DropdownItem>
</Dropdown>
The win:
- Accessibility built-in: The headless library handles ARIA attributes, keyboard navigation
- Your styles, your way: No fighting library styles
- Consistent behavior: Complex interactions work correctly out of the box
Pattern 8: The React Compiler (Automatic Memoization)
One of the biggest quality-of-life improvements in 2026: the React Compiler (formerly "React Forget") is now stable and enabled by default in Next.js 16.
Before: Manual Memoization (Tedious)
import { useMemo, useCallback } from 'react';
function ExpensiveComponent({ data, onItemClick }) {
// Manual memoization everywhere
const processedData = useMemo(() => {
return data.map(item => expensiveOperation(item));
}, [data]);
const handleClick = useCallback((id) => {
onItemClick(id);
}, [onItemClick]);
return (
<div>
{processedData.map(item => (
<button key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</button>
))}
</div>
);
}
After: Automatic Optimization
// next.config.ts
const nextConfig = {
reactCompiler: true, // Enable the compiler
};
export default nextConfig;
// Same component, no manual memoization needed
function ExpensiveComponent({ data, onItemClick }) {
// The compiler automatically memoizes this
const processedData = data.map(item => expensiveOperation(item));
// And this callback
const handleClick = (id) => {
onItemClick(id);
};
return (
<div>
{processedData.map(item => (
<button key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</button>
))}
</div>
);
}
What this means for you:
- Write simpler code: No more
useMemoanduseCallbackeverywhere - Fewer bugs: The compiler understands dependencies better than humans
- Better performance: Automatic optimization without mental overhead
- Focus on logic: Spend time on features, not performance tuning
Pattern 9: Parallel Data Fetching with Suspense
In 2026, we've finally internalized that waterfalls are bad. The pattern is now parallel fetching with Suspense boundaries.
The Anti-Pattern (Sequential)
// ❌ Bad - waits for each request to finish before starting the next
export default async function Dashboard() {
const user = await fetchUser(); // 1s
const posts = await fetchPosts(); // 1s after user
const analytics = await fetchAnalytics(); // 1s after posts
// Total: 3s
return <DashboardView user={user} posts={posts} analytics={analytics} />;
}
The Pattern (Parallel)
// ✅ Good - all requests start at the same time
export default async function Dashboard() {
// Start all fetches immediately
const userPromise = fetchUser();
const postsPromise = fetchPosts();
const analyticsPromise = fetchAnalytics();
// Wait for them in parallel
const [user, posts, analytics] = await Promise.all([
userPromise, postsPromise, analyticsPromise
]);
// Total: ~1s (the slowest request)
return <DashboardView user={user} posts={posts} analytics={analytics} />;
}
The Advanced Pattern (Streaming)
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserProfile } from './UserProfile';
import { UserPosts } from './UserPosts';
import { UserAnalytics } from './UserAnalytics';
export default function Dashboard() {
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold">Dashboard</h1>
{/* Each section streams independently */}
<Suspense fallback={<UserSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<UserPosts />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<UserAnalytics />
</Suspense>
</div>
);
}
// Each component fetches its own data in parallel
async function UserProfile() {
const user = await fetchUser(); // Takes 1s
return <div>{user.name}</div>;
}
async function UserPosts() {
const posts = await fetchPosts(); // Takes 2s
return <div>{posts.length} posts</div>;
}
async function UserAnalytics() {
const analytics = await fetchAnalytics(); // Takes 3s
return <div>{analytics.views} views</div>;
}
The user sees content progressively—the profile appears first, then posts, then analytics. No blank screens, no waiting for everything.
Pattern 10: Type-Safe Search Params
URL state is underrated. In 2026, we're using search params as the source of truth for UI state more than ever. And TypeScript makes it safe.
The Pattern
// app/products/page.tsx
import { Suspense } from 'react';
import { z } from 'zod';
// Define your params schema
const searchParamsSchema = z.object({
q: z.string().optional().default(''),
category: z.enum(['all', 'electronics', 'clothing', 'books']).optional().default('all'),
page: z.coerce.number().min(1).optional().default(1),
sort: z.enum(['price-asc', 'price-desc', 'newest']).optional().default('newest')
});
export default async function ProductsPage({
searchParams
}: {
searchParams: Promise<unknown> // Type-safe after parsing
}) {
// Validate and parse the params
const params = searchParamsSchema.parse(await searchParams);
const products = await db.product.findMany({
where: {
name: { contains: params.q },
category: params.category === 'all' ? undefined : params.category
},
orderBy: getSortClause(params.sort),
take: 20,
skip: (params.page - 1) * 20
});
return (
<div>
<SearchForm initialQuery={params.q} initialCategory={params.category} />
<ProductGrid products={products} />
<Pagination currentPage={params.page} />
</div>
);
}
// components/SearchForm.tsx
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
export function SearchForm({ initialQuery, initialCategory }: {
initialQuery: string;
initialCategory: string;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const params = new URLSearchParams(searchParams);
params.set('q', formData.get('q') as string);
params.set('category', formData.get('category') as string);
params.set('page', '1'); // Reset to first page on new search
startTransition(() => {
router.push(`/products?${params.toString()}`);
});
};
return (
<form onSubmit={handleSubmit} className="flex gap-4">
<input
name="q"
defaultValue={initialQuery}
placeholder="Search products..."
className="border rounded p-2"
/>
<select name="category" defaultValue={initialCategory} className="border rounded p-2">
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
</select>
<button
type="submit"
disabled={isPending}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
{isPending ? 'Searching...' : 'Search'}
</button>
</form>
);
}
Why this matters:
- Bookmarkable state: Users can share URLs and get the same view
- Back button works: Navigation is part of the browser history
- Type safety: Zod ensures you're always working with valid data
- Progressive enhancement: Works even without JavaScript
Pattern 11: Turbopack for Lightning-Fast Development
Next.js 16 makes Turbopack the default bundler. If you haven't experienced it yet, you're in for a treat.
What Turbopack Gives You
# Before (webpack)
✓ Compiled in 2.4s
# After (Turbopack)
✓ Compiled in 615ms
The numbers:
- 10x faster Fast Refresh: Code updates appear almost instantly
- 2-5x faster production builds: CI/CD pipelines finish sooner
- <50ms refresh time: Even with 10,000+ modules
Enable File System Caching
// next.config.ts
import type { NextConfig } from "next";
const nextConfig = {
experimental: {
turbopackFileSystemCacheForDev: true, // Even faster restarts
},
};
export default nextConfig;
If you need webpack for legacy reasons, you can still opt out:
const nextConfig = {
turbopack: false, // Falls back to webpack
};
But honestly, you won't want to.
Pattern 12: AI-Assisted Development with MCP
This one feels like science fiction, but it's real in 2026. Next.js DevTools now integrate with the Model Context Protocol (MCP) , giving AI assistants deep context about your application.
What It Does
# When you encounter an error, your AI assistant automatically knows:
- The exact route where it occurred
- Your caching configuration
- Recent build times
- Bundle size impact
- Suggested fixes based on best practices
Real capabilities:
- Context-aware insights: AI understands your app's routing, caching, and rendering
- Automatic error access: Detailed stack traces with route context
- Fix suggestions: AI proposes specific code corrections
How to Use It
# Enable in your Next.js configuration
// next.config.ts
const nextConfig = {
mcp: true, // Enable AI debugging context
};
This doesn't replace your judgment—but it handles the mechanical work of debugging, freeing you to think about architecture.
Pattern 13: The Production Readiness Checklist
Before you deploy in 2026, run through this checklist. These aren't nice-to-haves—they're expectations.
✅ Core Web Vitals
// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next';
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<SpeedInsights /> {/* Automatic performance monitoring */}
<Analytics /> {/* Usage analytics */}
</body>
</html>
);
}
✅ Bundle Analysis
npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your config
});
ANALYZE=true npm run build
✅ Security Essentials
// middleware.ts (or proxy.ts in Next.js 16)
import { NextResponse } from 'next/server';
export function middleware(request) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`;
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', cspHeader.replace(/\s{2,}/g, ' ').trim());
return response;
}
✅ Error Handling
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-red-600">Something went wrong!</h1>
<p className="mt-4 text-gray-600">{error.message}</p>
<button
onClick={reset}
className="mt-6 px-4 py-2 bg-blue-500 text-white rounded"
>
Try again
</button>
</div>
</div>
</body>
</html>
);
}
✅ 404 Page
// app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-6xl font-bold text-gray-900">404</h1>
<p className="mt-4 text-xl text-gray-600">Page not found</p>
<Link
href="/"
className="mt-6 inline-block px-4 py-2 bg-blue-500 text-white rounded"
>
Go home
</Link>
</div>
</div>
);
}
Putting It All Together: A Real Example
Let's see these patterns in action with a complete feature: a product catalog with search, filtering, and a shopping cart.
The File Structure (Feature-Sliced)
src/
├── app/
│ └── products/
│ └── page.tsx # Route (minimal)
├── features/
│ ├── product-catalog/
│ │ ├── ui/
│ │ │ ├── ProductGrid.tsx
│ │ │ ├── ProductCard.tsx
│ │ │ └── SearchForm.tsx
│ │ ├── api/
│ │ │ └── getProducts.ts
│ │ └── lib/
│ │ └── validation.ts
│ └── cart/
│ ├── ui/
│ │ ├── CartDrawer.tsx
│ │ └── AddToCartButton.tsx
│ ├── api/
│ │ └── cartActions.ts
│ └── lib/
│ └── cartStore.ts
├── shared/
│ ├── ui/
│ │ ├── Button.tsx
│ │ └── Spinner.tsx
│ └── lib/
│ └── utils.ts
└── entities/
└── product/
└── types.ts
The Code
// app/products/page.tsx
import { ProductCatalogPage } from '@/features/product-catalog/ui/ProductCatalogPage';
export default function Page() {
return <ProductCatalogPage />;
}
// features/product-catalog/ui/ProductCatalogPage.tsx
import { Suspense } from 'react';
import { z } from 'zod';
import { ProductGrid } from './ProductGrid';
import { SearchForm } from './SearchForm';
import { getProducts } from '../api/getProducts';
const searchParamsSchema = z.object({
q: z.string().optional().default(''),
category: z.enum(['all', 'electronics', 'clothing']).optional().default('all'),
page: z.coerce.number().min(1).optional().default(1),
});
export default async function ProductCatalogPage({
searchParams
}: {
searchParams: Promise<unknown>
}) {
const params = searchParamsSchema.parse(await searchParams);
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Products</h1>
<SearchForm
initialQuery={params.q}
initialCategory={params.category}
/>
<Suspense fallback={<ProductGridSkeleton />} key={JSON.stringify(params)}>
<ProductGrid params={params} />
</Suspense>
</div>
);
}
// features/product-catalog/ui/ProductGrid.tsx
import { getProducts } from '../api/getProducts';
import { ProductCard } from './ProductCard';
import { AddToCartButton } from '@/features/cart/ui/AddToCartButton';
export async function ProductGrid({ params }: { params: any }) {
const { products, totalPages } = await getProducts(params);
if (products.length === 0) {
return <div className="text-center py-12">No products found</div>;
}
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products.map(product => (
<div key={product.id} className="border rounded-lg p-4">
<ProductCard product={product} />
<div className="mt-4">
<AddToCartButton productId={product.id} />
</div>
</div>
))}
</div>
{totalPages > 1 && (
<Pagination
currentPage={params.page}
totalPages={totalPages}
/>
)}
</div>
);
}
// features/cart/ui/AddToCartButton.tsx
'use client';
import { useTransition } from 'react';
import { addToCart } from '../api/cartActions';
import { Button } from '@/shared/ui/Button';
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(async () => {
await addToCart(productId);
});
};
return (
<Button
onClick={handleClick}
disabled={isPending}
size="sm"
className="w-full"
>
{isPending ? 'Adding...' : 'Add to Cart'}
</Button>
);
}
// features/cart/api/cartActions.ts
'use server';
import { revalidateTag } from 'next/cache';
import { db } from '@/lib/db';
import { getCurrentUser } from '@/lib/auth';
import { z } from 'zod';
export async function addToCart(productId: string) {
// Validate
z.string().uuid().parse(productId);
// Authorize
const user = await getCurrentUser();
if (!user) {
throw new Error('Must be logged in');
}
// Get or create cart
let cart = await db.cart.findUnique({
where: { userId: user.id }
});
if (!cart) {
cart = await db.cart.create({
data: { userId: user.id }
});
}
// Add item
await db.cartItem.create({
data: {
cartId: cart.id,
productId,
quantity: 1
}
});
// Revalidate
revalidateTag(`cart-${user.id}`);
}
// shared/ui/Button.tsx
import { Slot } from '@radix-ui/react-slot';
import { forwardRef } from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
variant?: 'primary' | 'secondary' | 'outline';
size?: 'sm' | 'md' | 'lg';
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild = false, variant = 'primary', size = 'md', className, children, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
const baseClasses = "inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2";
const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500",
outline: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus:ring-blue-500"
};
const sizes = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg"
};
return (
<Comp
ref={ref}
className={`${baseClasses} ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</Comp>
);
}
);
Button.displayName = 'Button';
What the Future Holds
As we look beyond 2026, a few trends are clear:
Server-first is here to stay. The mental model shift from client-rendered SPAs to server-rendered hybrids has taken hold, and it's delivering real performance gains.
Build tools are finally fast enough. With Turbopack and Vite 7, the days of waiting for builds are ending. Development feels instant.
CSS caught up. Container queries, :has(), and native nesting mean we reach for preprocessors less often.
AI is a teammate, not a replacement. Tools that understand your codebase help with boilerplate and debugging, but architecture decisions still require human judgment.
Multiple runtimes are normal. Node.js, Bun, Deno—your code should be portable enough to run anywhere.
What I'd Tell My 2020 Self
If I could go back and give myself advice before learning these patterns, here's what I'd say:
Start with the platform. Understand Server Components before you write any client code. They're the default now.
Structure matters more than syntax. Feature-Sliced Design saved my sanity on large projects. Organize by domain, not by file type.
Let the compiler handle performance. React Compiler means you can stop sprinkling useMemo everywhere. Write simple code, get fast results.
Embrace URL state. Search params are underrated. They make your app bookmarkable, shareable, and easier to debug.
Measure before you optimize. Use the tools—Lighthouse, Core Web Vitals, bundle analyzers. Know where your bottlenecks actually are.
Stay curious, but skeptical. New patterns emerge constantly. Adopt the ones that solve real problems, not the ones that just look cool on Twitter.
That junior developer I was onboarding? She picked up these patterns in about two weeks. By the end of the month, she was shipping features faster than developers with twice her experience.
Not because she was brilliant (though she is). But because she learned the right patterns from the start.
Now it's your turn.
Enjoyed this post? I write about React, Next.js, and modern web development every Month. Follow me on Twitter @themarvelbiz.
