| name | Next.js 15+ Patterns |
| description | App Router, React Server Components, Server Actions (2025 best practices) |
Next.js 15+ Development Patterns
Modern Next.js development using App Router, React Server Components, and Server Actions.
App Router Structure
File-Based Routing
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Home page
├── loading.tsx # Loading UI
├── error.tsx # Error UI
├── not-found.tsx # 404 page
├── template.tsx # Re-renders on navigation
├── (marketing)/ # Route group (doesn't affect URL)
│ ├── about/
│ │ └── page.tsx
│ └── contact/
│ └── page.tsx
├── blog/
│ ├── [slug]/ # Dynamic route
│ │ └── page.tsx
│ └── page.tsx
└── api/
└── users/
└── route.ts # API route
Routing Conventions
page.tsx- UI for routelayout.tsx- Shared UI for segment and childrenloading.tsx- Loading UI with Suspenseerror.tsx- Error UI with Error Boundarynot-found.tsx- 404 UIroute.ts- API endpoint
Server vs Client Components
Server Components (Default)
// app/page.tsx - Server Component by default
import { db } from '@/lib/db';
export default async function HomePage() {
// Can directly access database
const posts = await db.post.findMany();
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
Benefits:
- Direct database access
- No client-side JS bundle
- Automatic code splitting
- Better SEO
When to use:
- Data fetching
- Backend logic
- Static content
- SEO-critical pages
Client Components
'use client'; // Required directive
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
Require 'use client' when:
- Using hooks (useState, useEffect, etc.)
- Event handlers (onClick, onChange, etc.)
- Browser APIs (window, localStorage, etc.)
- Third-party libraries that use hooks
Best Practice: Push 'use client' as deep as possible
// ✅ Good: Only interactive part is client
export default async function Page() {
const data = await fetchData(); // Server
return (
<div>
<StaticHeader data={data} /> {/* Server */}
<InteractiveButton /> {/* Client */}
</div>
);
}
Server Actions
Form Handling
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validate
if (!title || !content) {
return { error: 'Title and content required' };
}
// Mutate
await db.post.create({
data: { title, content }
});
// Revalidate
revalidatePath('/blog');
return { success: true };
}
// app/new-post/page.tsx
import { createPost } from '@/app/actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create</button>
</form>
);
}
Progressive Enhancement: Works without JavaScript!
With useFormState (Client)
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions';
export default function NewPostForm() {
const [state, formAction] = useFormState(createPost, { error: null });
return (
<form action={formAction}>
{state.error && <p className="error">{state.error}</p>}
<input name="title" required />
<textarea name="content" required />
<SubmitButton />
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create'}
</button>
);
}
Data Fetching
Server Component Data Fetching
// Automatic request deduplication
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { revalidate: 60 } // Revalidate every 60 seconds
});
return res.json();
}
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
return <div>{post.title}</div>;
}
Parallel Data Fetching
export default async function Page() {
// Fetch in parallel
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
]);
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
<Comments comments={comments} />
</div>
);
}
Streaming with Suspense
import { Suspense } from 'react';
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
{/* Render immediately */}
<Suspense fallback={<SkeletonUsers />}>
<Users />
</Suspense>
{/* Stream when ready */}
<Suspense fallback={<SkeletonPosts />}>
<Posts />
</Suspense>
</div>
);
}
async function Users() {
const users = await fetchUsers(); // Can be slow
return <UserList users={users} />;
}
Caching & Revalidation
Cache Strategies
// Static (cache indefinitely)
fetch('https://api.example.com/data', {
cache: 'force-cache'
});
// Dynamic (no cache)
fetch('https://api.example.com/data', {
cache: 'no-store'
});
// Revalidate (cache with TTL)
fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // 1 hour
});
// Tag-based (revalidate by tag)
fetch('https://api.example.com/data', {
next: { tags: ['posts'] }
});
Revalidation Methods
// app/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function updatePost(id: string) {
await db.post.update({ where: { id }, data: { ... } });
// Revalidate specific path
revalidatePath('/blog');
revalidatePath(`/blog/${id}`);
// Or revalidate by tag
revalidateTag('posts');
}
Metadata & SEO
Static Metadata
// app/blog/page.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Blog',
description: 'My blog posts',
openGraph: {
title: 'Blog',
description: 'My blog posts',
images: ['/og-image.jpg']
}
};
export default function BlogPage() {
return <div>...</div>;
}
Dynamic Metadata
export async function generateMetadata({
params
}: {
params: { slug: string }
}): Promise<Metadata> {
const post = await fetchPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.ogImage]
}
};
}
Route Handlers (API Routes)
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
const users = await db.user.findMany();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const user = await db.user.create({
data: body
});
return NextResponse.json(user, { status: 201 });
}
Dynamic Route Handlers
// app/api/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({
where: { id: params.id }
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json(user);
}
Performance Optimization
Image Optimization
import Image from 'next/image';
export default function Avatar() {
return (
<Image
src="/avatar.jpg"
alt="User avatar"
width={200}
height={200}
priority // Above fold
placeholder="blur" // Show blur while loading
blurDataURL="data:image/..." // Inline blur
/>
);
}
Font Optimization
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter'
});
export default function RootLayout({ children }) {
return (
<html className={inter.variable}>
<body>{children}</body>
</html>
);
}
Code Splitting
import dynamic from 'next/dynamic';
// Lazy load heavy component
const HeavyChart = dynamic(() => import('@/components/Chart'), {
loading: () => <p>Loading chart...</p>,
ssr: false // Don't render on server
});
Error Handling
Error Boundaries
// app/blog/error.tsx
'use client';
export default function Error({
error,
reset
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
Not Found
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
return <h2>Blog post not found</h2>;
}
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function PostPage({ params }) {
const post = await fetchPost(params.slug);
if (!post) {
notFound(); // Renders not-found.tsx
}
return <div>{post.title}</div>;
}
Common Patterns
Loading States
// Use Suspense for granular loading
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>
// Or loading.tsx for entire route
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />;
}
Optimistic Updates
'use client';
import { useOptimistic } from 'react';
import { likePost } from '@/app/actions';
export default function LikeButton({ postId, likes }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(state, newLike) => state + 1
);
async function handleLike() {
addOptimisticLike(1); // Update UI immediately
await likePost(postId); // Then update server
}
return (
<button onClick={handleLike}>
Likes: {optimisticLikes}
</button>
);
}
Anti-Patterns to Avoid
❌ Don't use 'use client' at root unnecessarily
// Bad - entire page is client
'use client';
export default function Page() {
return <div>Static content</div>;
}
❌ Don't fetch in Client Components
// Bad - defeats purpose of Server Components
'use client';
export default function Users() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users').then(/* ... */);
}, []);
// Use Server Component instead!
}
❌ Don't forget revalidation after mutations
// Bad - stale data
export async function deletePost(id) {
await db.post.delete({ where: { id } });
// Missing: revalidatePath('/blog');
}
Resources
See resources/ directory for detailed patterns:
- app-router.md - Advanced routing
- server-components.md - Deep dive
- server-actions.md - Form handling
- data-fetching.md - Caching strategies
- performance.md - Optimization techniques