Next.js 15 App Router: Production Patterns and Best Practices
Next.js 15 with the App Router has transformed how we build React applications. After deploying multiple production apps, here are the patterns that actually work.
The App Router Revolution
The App Router isn't just a new routing system—it's a paradigm shift in how we think about React applications. Server components, streaming, and nested layouts change everything.
Key Changes from Pages Router
- Server Components by default
- Streaming SSR with Suspense
- Nested layouts and route groups
- Parallel routes and intercepting routes
- Server actions for mutations
Project Structure That Scales
Organized App Directory
app/
├── (auth)/
│ ├── login/
│ └── register/
├── (dashboard)/
│ ├── layout.tsx
│ ├── page.tsx
│ └── settings/
├── api/
│ └── users/
├── globals.css
├── layout.tsx
├── page.tsx
└── loading.tsx
Route Groups for Organization
// app/(dashboard)/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
);
}
// app/(auth)/layout.tsx
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="auth-container">
<div className="auth-card">{children}</div>
</div>
);
}
Server Components in Production
When to Use Server Components
// ✅ Good for data fetching
async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId);
return (
<div className="profile">
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// ❌ Don't use for interactive elements
function InteractiveProfile({ userId }: { userId: string }) {
// This needs to be a client component
const [isEditing, setIsEditing] = useState(false);
return (
<div>
<UserProfile userId={userId} />
<button onClick={() => setIsEditing(true)}>
Edit Profile
</button>
</div>
);
}
Data Fetching Patterns
// Parallel data fetching
async function DashboardPage() {
const [user, posts, analytics] = await Promise.all([
getUser(),
getPosts(),
getAnalytics(),
]);
return (
<div>
<UserProfile user={user} />
<PostsList posts={posts} />
<AnalyticsDashboard data={analytics} />
</div>
);
}
// Streaming with Suspense
function PostsPage() {
return (
<div>
<h1>Posts</h1>
<Suspense fallback={<PostsSkeleton />}>
<PostsList />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<PostsAnalytics />
</Suspense>
</div>
);
}
async function PostsList() {
const posts = await getPosts();
return (
<div className="posts-grid">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
Client Components Best Practices
Minimal Client Boundaries
// ❌ Too much client code
export default function BigClientComponent() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData().then(setData).finally(() => setLoading(false));
}, []);
if (loading) return <div>Loading...</div>;
return (
<div>
<Header data={data} />
<Content data={data} />
<Footer data={data} />
</div>
);
}
// ✅ Server component with minimal client boundary
async function Page() {
const data = await fetchData();
return (
<div>
<Header data={data} />
<Content data={data} />
<InteractiveFooter data={data} />
</div>
);
}
function InteractiveFooter({ data }: { data: any }) {
'use client';
const [isOpen, setIsOpen] = useState(false);
return (
<footer>
<button onClick={() => setIsOpen(!isOpen)}>
Toggle Details
</button>
{isOpen && <FooterDetails data={data} />}
</footer>
);
}
Server Actions for Mutations
Form Handling with Server Actions
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validation
if (!title || !content) {
return { error: 'Title and content are required' };
}
try {
const post = await db.post.create({
data: { title, content },
});
// Revalidate the posts page
revalidatePath('/posts');
return { success: true, post };
} catch (error) {
return { error: 'Failed to create post' };
}
}
// app/posts/create/page.tsx
import { createPost } from '@/app/actions/posts';
export default function CreatePostPage() {
return (
<form action={createPost}>
<div>
<label htmlFor="title">Title</label>
<input id="title" name="title" required />
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
</div>
<button type="submit">Create Post</button>
</form>
);
}
Progressive Enhancement
// With loading states and optimistic updates
'use client';
import { useFormStatus, useFormState } from 'react-dom';
import { createPost } from '@/app/actions/posts';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}
const initialState = { error: null, success: false };
export function CreatePostForm() {
const [state, formAction] = useFormState(createPost, initialState);
return (
<form action={formAction}>
{state.error && (
<div className="error">{state.error}</div>
)}
{state.success && (
<div className="success">Post created successfully!</div>
)}
<div>
<label htmlFor="title">Title</label>
<input id="title" name="title" required />
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
</div>
<SubmitButton />
</form>
);
}
Advanced Routing Patterns
Parallel Routes
// app/@analytics/page.tsx
export default function AnalyticsSidebar() {
const analytics = await getAnalytics();
return (
<aside className="analytics-sidebar">
<h2>Analytics</h2>
<AnalyticsChart data={analytics} />
</aside>
);
}
// app/layout.tsx
export default function RootLayout({
children,
analytics,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<html>
<body>
<div className="main-content">
{children}
</div>
<div className="sidebar">
{analytics}
</div>
</body>
</html>
);
}
Intercepting Routes
// app/photo/[id]/@modal/page.tsx
export default function PhotoModal({ params }: { params: { id: string } }) {
const photo = await getPhoto(params.id);
return (
<div className="modal-backdrop">
<div className="modal-content">
<img src={photo.url} alt={photo.title} />
<h2>{photo.title}</h2>
<p>{photo.description}</p>
</div>
</div>
);
}
// app/photo/[id]/page.tsx
export default function PhotoPage({ params }: { params: { id: string } }) {
const photo = await getPhoto(params.id);
return (
<div className="photo-page">
<img src={photo.url} alt={photo.title} />
<h1>{photo.title}</h1>
<p>{photo.description}</p>
</div>
);
}
Performance Optimization
Image Optimization
import Image from 'next/image';
// ✅ Optimized images
function ProductCard({ product }: { product: Product }) {
return (
<div className="product-card">
<Image
src={product.image}
alt={product.name}
width={300}
height={200}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
priority={product.featured}
/>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
);
}
// Dynamic images with blur placeholders
function generateBlurDataURL(imageUrl: string) {
// Generate a low-quality placeholder
return imageUrl + '?w=10&h=10&blur=10';
}
Code Splitting Strategies
// Dynamic imports for large components
const AdminDashboard = dynamic(
() => import('@/components/admin/AdminDashboard'),
{
loading: () => <AdminDashboardSkeleton />,
ssr: false // Only load on client if needed
}
);
// Route-based code splitting
function ConditionalAdmin({ isAdmin }: { isAdmin: boolean }) {
if (!isAdmin) return null;
return <AdminDashboard />;
}
// Lazy loading heavy libraries
const Chart = dynamic(() => import('recharts'), {
ssr: false,
loading: () => <ChartSkeleton />
});
Caching Strategies
Data Caching
// app/api/posts/route.ts
export async function GET() {
// Cache for 5 minutes
const posts = await cache(
() => getPostsFromDatabase(),
['posts'],
{ revalidate: 300 }
);
return Response.json(posts);
}
// Page-level caching
export const revalidate = 3600; // Revalidate every hour
async function PostsPage() {
const posts = await getPosts(); // Uses cached data
return (
<div>
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
Static Generation with ISR
// Generate static pages with incremental updates
export async function generateStaticParams() {
const posts = await getPopularPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export const revalidate = 86400; // Revalidate daily
async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
Error Handling
Error Boundaries
// app/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log error to reporting service
console.error(error);
}, [error]);
return (
<div className="error-boundary">
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
// app/posts/error.tsx
export default function PostsError() {
return (
<div className="posts-error">
<h2>Unable to load posts</h2>
<p>Please try again later.</p>
</div>
);
}
404 Handling
// app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="not-found">
<h2>Page not found</h2>
<p>Sorry, we couldn't find the page you're looking for.</p>
<Link href="/">Go home</Link>
</div>
);
}
// app/posts/[slug]/not-found.tsx
export default function PostNotFound() {
return (
<div className="post-not-found">
<h2>Post not found</h2>
<p>This post doesn't exist or has been removed.</p>
<Link href="/posts">Browse all posts</Link>
</div>
);
}
Testing Strategies
Component Testing
// __tests__/components/PostCard.test.tsx
import { render, screen } from '@testing-library/react';
import PostCard from '@/components/PostCard';
// Mock server component
jest.mock('@/app/actions/posts', () => ({
getPost: jest.fn(),
}));
describe('PostCard', () => {
it('renders post information correctly', () => {
const mockPost = {
id: 1,
title: 'Test Post',
content: 'Test content',
};
render(<PostCard post={mockPost} />);
expect(screen.getByText('Test Post')).toBeInTheDocument();
expect(screen.getByText('Test content')).toBeInTheDocument();
});
});
API Route Testing
// __tests__/api/posts.test.ts
import { createMocks } from 'node-mocks-http';
import handler from '@/app/api/posts/route';
describe('/api/posts', () => {
it('returns posts list', async () => {
const { req } = createMocks({
method: 'GET',
});
const response = await handler(req);
const data = await response.json();
expect(response.status).toBe(200);
expect(Array.isArray(data)).toBe(true);
});
});
Deployment Considerations
Environment Variables
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
env: {
CUSTOM_KEY: process.env.CUSTOM_KEY,
},
};
// Runtime environment checks
function getDatabaseUrl() {
if (typeof window !== 'undefined') {
throw new Error('Database URL should only be used on server');
}
return process.env.DATABASE_URL;
}
Build Optimization
// next.config.js
const nextConfig = {
experimental: {
optimizePackageImports: ['lucide-react', '@radix-ui/react-icons'],
},
images: {
domains: ['example.com'],
formats: ['image/webp', 'image/avif'],
},
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
};
Monitoring and Analytics
Performance Monitoring
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}
// Custom performance tracking
function trackPageView(page: string) {
if (typeof window !== 'undefined') {
// Track page view
gtag('config', 'GA_MEASUREMENT_ID', {
page_path: page,
});
}
}
Common Pitfalls to Avoid
1. Overusing Client Components
// ❌ Making everything client
'use client';
function EntirePage() {
// All data fetching happens on client
const [data, setData] = useState(null);
// ...
}
// ✅ Server-first approach
async function Page() {
const data = await getData();
return <ClientComponent data={data} />;
}
2. Ignoring Loading States
// ❌ No loading states
function SlowPage() {
const data = useSlowData(); // Flash of content
return <div>{data}</div>;
}
// ✅ Proper loading states
function SlowPage() {
return (
<Suspense fallback={<LoadingSkeleton />}>
<SlowDataComponent />
</Suspense>
);
}
3. Not Optimizing Images
// ❌ Unoptimized images
<img src="/large-image.jpg" alt="Large image" />
// ✅ Optimized with Next.js Image
<Image
src="/large-image.jpg"
alt="Large image"
width={800}
height={600}
priority
/>
Conclusion
Next.js 15 with the App Router provides incredible power when used correctly. The key is understanding the server/client boundary and leveraging server components effectively.
Start with server components, add client interactivity only when needed, and always consider the user experience with proper loading states and error handling.
The patterns I've shared have been battle-tested in production applications. They'll help you build scalable, performant Next.js applications that delight users.
What Next.js 15 patterns have you discovered? Share your experiences in the comments!