Back to blog

Next.js 15 App Router: Production Patterns and Best Practices

January 20, 2025 (1y ago)

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!

Share this post